+
-
-
- Total Categories
-
-
- {categories.length}
-
-
+
diff --git a/apps/web/src/components/dash/admin/events/EventStatsSheet.tsx b/apps/web/src/components/dash/admin/events/EventStatsSheet.tsx
index 3130faff..39f4a390 100644
--- a/apps/web/src/components/dash/admin/events/EventStatsSheet.tsx
+++ b/apps/web/src/components/dash/admin/events/EventStatsSheet.tsx
@@ -1,38 +1,29 @@
import React from "react";
import { getEventStatsOverview } from "@/lib/queries/events";
-
-import { Separator } from "@/components/ui/separator";
+import { StatItemProps } from "@/components/dash/shared/StatItem";
+import StatsSheet from "@/components/dash/shared/StatsSheet";
type Props = {};
async function EventStatsSheet({}: Props) {
const stats = await getEventStatsOverview();
- return (
-
-
-
- Total Events
-
-
- {stats.totalEvents}
-
-
-
-
- This Week
- {stats.thisWeek}
-
-
-
-
- Past Events
-
-
- {stats.pastEvents}
-
-
-
- );
+
+ const statItems: StatItemProps[] = [
+ {
+ label: "Total Events",
+ value: stats.totalEvents,
+ },
+ {
+ label: "This Week",
+ value: stats.thisWeek,
+ },
+ {
+ label: "Past Events",
+ value: stats.pastEvents,
+ },
+ ];
+
+ return
;
}
export default EventStatsSheet;
diff --git a/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx b/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx
index c1611c77..3f0d2207 100644
--- a/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx
+++ b/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx
@@ -1,39 +1,25 @@
import React from "react";
import { getMemberStatsOverview } from "@/lib/queries/users";
-
-import { Separator } from "@/components/ui/separator";
+import { StatItemProps } from "@/components/dash/shared/StatItem";
+import StatsSheet from "@/components/dash/shared/StatsSheet";
type Props = {};
async function MemberStatsSheet({}: Props) {
const stats = await getMemberStatsOverview();
- return (
-
-
-
- Total Members
-
-
- {stats.totalMembers}
-
-
-
- {/* Put recent registration count here */}
- {/*
- This Week
- {stats.thisWeek}
-
-
*/}
-
-
- Active Members
-
-
- {stats.activeMembers}
-
-
-
- );
+
+ const statItems: StatItemProps[] = [
+ {
+ label: "Total Members",
+ value: stats.totalMembers,
+ },
+ {
+ label: "Active Members",
+ value: stats.activeMembers,
+ },
+ ];
+
+ return
;
}
export default MemberStatsSheet;
diff --git a/apps/web/src/components/dash/admin/overview/ActivityByDayChart.tsx b/apps/web/src/components/dash/admin/overview/ActivityByDayChart.tsx
new file mode 100644
index 00000000..e477c3e8
--- /dev/null
+++ b/apps/web/src/components/dash/admin/overview/ActivityByDayChart.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import React from "react";
+import { CalendarDaysIcon } from "lucide-react";
+
+type DayActivity = {
+ day: string;
+ count: number;
+};
+
+type Props = {
+ activityByDayOfWeek: DayActivity[];
+ mostActiveDay: string;
+};
+
+export default function ActivityByDayChart({
+ activityByDayOfWeek,
+ mostActiveDay,
+}: Props) {
+ // Find the highest count to calculate relative heights
+ const maxCount = Math.max(...activityByDayOfWeek.map((day) => day.count));
+
+ return (
+
+
+
{mostActiveDay}
+
+ {activityByDayOfWeek.map((day) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/dash/admin/overview/ActivityPatterns.tsx b/apps/web/src/components/dash/admin/overview/ActivityPatterns.tsx
new file mode 100644
index 00000000..8c0bfc47
--- /dev/null
+++ b/apps/web/src/components/dash/admin/overview/ActivityPatterns.tsx
@@ -0,0 +1,110 @@
+import { Suspense } from "react";
+import {
+ getActivityByTimeOfDay,
+ getMembershipStatus,
+} from "@/lib/queries/charts";
+import TimeOfDayChart from "./TimeOfDayChart";
+import MembershipStatusChart from "./MembershipStatusChart";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from "@/components/ui/card";
+
+export default function ActivityPatterns() {
+ return (
+
+
+
+ Activity Patterns
+
+
+
+
+ }
+ >
+
+
+
+ }
+ >
+
+
+
+
+ );
+}
+
+function LoadingCard({
+ title,
+ description,
+}: {
+ title: string;
+ description: string;
+}) {
+ return (
+
+
+ {title}
+
+ {description}
+
+
+
+
+ Loading...
+
+
+
+ );
+}
+
+async function TimeOfDayCard() {
+ const activityByTimeOfDay = await getActivityByTimeOfDay();
+
+ return (
+
+
+
+ Activity by Time of Day
+
+
+ When members most frequently visit
+
+
+
+
+
+
+ );
+}
+
+async function MembershipStatusCard() {
+ const membershipStatus = await getMembershipStatus();
+
+ return (
+
+
+ Membership Status
+
+ Distribution of member statuses
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/dash/admin/overview/AverageVisitsCard.tsx b/apps/web/src/components/dash/admin/overview/AverageVisitsCard.tsx
new file mode 100644
index 00000000..26d9beae
--- /dev/null
+++ b/apps/web/src/components/dash/admin/overview/AverageVisitsCard.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import React from "react";
+
+type Props = {
+ averageVisits: string;
+};
+
+export default function AverageVisitsCard({ averageVisits }: Props) {
+ return (
+
+
+
{averageVisits}
+
+ visits/member
+
+
+
+ );
+}
diff --git a/apps/web/src/components/dash/admin/overview/DemographicsSection.tsx b/apps/web/src/components/dash/admin/overview/DemographicsSection.tsx
new file mode 100644
index 00000000..73b24828
--- /dev/null
+++ b/apps/web/src/components/dash/admin/overview/DemographicsSection.tsx
@@ -0,0 +1,139 @@
+import { Suspense } from "react";
+import {
+ getUserClassifications,
+ getGenderDistribution,
+ getRaceDistribution,
+} from "@/lib/queries/charts";
+import DemographicsStats from "./DemographicsStats";
+import GenderDistributionChart from "./GenderDistributionChart";
+import RaceDistributionChart from "./RaceDistributionChart";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from "@/components/ui/card";
+
+export default function DemographicsSection() {
+ return (
+
+
+
+ Member Demographics
+
+
+
+
+
+ }
+ >
+
+
+
+
+ }
+ >
+
+
+
+
+ }
+ >
+
+
+
+
+ );
+}
+
+function LoadingCard({
+ title,
+ description,
+}: {
+ title: string;
+ description: string;
+}) {
+ return (
+
+
+ {title}
+
+ {description}
+
+
+
+
+ Loading...
+
+
+
+ );
+}
+
+async function ClassificationCard() {
+ const classifications = await getUserClassifications();
+
+ return (
+
+
+ Member Classification
+ Distribution by member level
+
+
+
+
+
+ );
+}
+
+async function GenderCard() {
+ const genderData = await getGenderDistribution();
+
+ return (
+
+
+ Gender Distribution
+
+ Breakdown of members by gender
+
+
+
+
+
+
+ );
+}
+
+async function RaceCard() {
+ const raceData = await getRaceDistribution();
+
+ return (
+
+
+ Race/Ethnicity
+
+ Breakdown of members by race/ethnicity
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/dash/admin/overview/DemographicsStats.tsx b/apps/web/src/components/dash/admin/overview/DemographicsStats.tsx
index e368d93c..3cebb45f 100644
--- a/apps/web/src/components/dash/admin/overview/DemographicsStats.tsx
+++ b/apps/web/src/components/dash/admin/overview/DemographicsStats.tsx
@@ -61,37 +61,34 @@ function DemographicsStats({ classifications }: Props) {
return (
-
-
- Classifications
-
-
-
-
- }
- />
-
-
- }
- className="-translate-y-2 flex-wrap gap-2 [&>*]:basis-1/4 [&>*]:justify-center"
- />
-
-
-
-
+
+
+ }
+ />
+
+ } />
+
+ }
+ layout="horizontal"
+ verticalAlign="bottom"
+ align="center"
+ />
+
+
);
diff --git a/apps/web/src/components/dash/admin/overview/EngagementMetricsCard.tsx b/apps/web/src/components/dash/admin/overview/EngagementMetricsCard.tsx
new file mode 100644
index 00000000..270fd775
--- /dev/null
+++ b/apps/web/src/components/dash/admin/overview/EngagementMetricsCard.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import React from "react";
+import { Progress } from "@/components/ui/progress";
+
+type Props = {
+ activeMembers: number;
+ totalRegistrations: number;
+};
+
+export default function EngagementMetricsCard({
+ activeMembers,
+ totalRegistrations,
+}: Props) {
+ const activePercentage = Math.round(
+ (activeMembers / totalRegistrations) * 100,
+ );
+
+ return (
+
+
+
{activeMembers}
+
+ of {totalRegistrations} ({activePercentage}%)
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/dash/admin/overview/EngagementSection.tsx b/apps/web/src/components/dash/admin/overview/EngagementSection.tsx
new file mode 100644
index 00000000..40e99cf4
--- /dev/null
+++ b/apps/web/src/components/dash/admin/overview/EngagementSection.tsx
@@ -0,0 +1,171 @@
+import { Suspense } from "react";
+import {
+ getActiveMembers,
+ getRegistrationsByMonth,
+ getCheckinsByMonth,
+ getMostActiveDay,
+ getActivityByDayOfWeek,
+} from "@/lib/queries/charts";
+import EngagementMetricsCard from "./EngagementMetricsCard";
+import AverageVisitsCard from "./AverageVisitsCard";
+import ActivityByDayChart from "./ActivityByDayChart";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from "@/components/ui/card";
+
+export default function EngagementSection() {
+ return (
+
+
+
+ Engagement Metrics
+
+
+
+
+ }
+ >
+
+
+
+ }
+ >
+
+
+
+ }
+ >
+
+
+
+
+ );
+}
+
+function LoadingCard({
+ title,
+ description,
+}: {
+ title: string;
+ description: string;
+}) {
+ return (
+
+
+ {title}
+
+ {description}
+
+
+
+
+ Loading...
+
+
+
+ );
+}
+
+async function ActiveMembersCard() {
+ const [activeMembers, registrations] = await Promise.all([
+ getActiveMembers(),
+ getRegistrationsByMonth(),
+ ]);
+
+ const totalRegistrations = registrations.reduce(
+ (acc, curr) => acc + curr.count,
+ 0,
+ );
+
+ return (
+
+
+ Active Members
+
+ Members who visited in the last 30 days
+
+
+
+
+
+
+ );
+}
+
+async function VisitsCard() {
+ const [registrations, checkins] = await Promise.all([
+ getRegistrationsByMonth(),
+ getCheckinsByMonth(),
+ ]);
+
+ const totalRegistrations = registrations.reduce(
+ (acc, curr) => acc + curr.count,
+ 0,
+ );
+ const totalCheckins = checkins.reduce((acc, curr) => acc + curr.count, 0);
+ const averageVisitsPerMember = (
+ totalCheckins / (totalRegistrations || 1)
+ ).toFixed(1);
+
+ return (
+
+
+
+ Avg. Visits per Member
+
+
+ Average check-ins per registered member
+
+
+
+
+
+
+ );
+}
+
+async function ActiveDayCard() {
+ const [mostActiveDay, activityByDayOfWeek] = await Promise.all([
+ getMostActiveDay(),
+ getActivityByDayOfWeek(),
+ ]);
+
+ return (
+
+
+ Most Active Day
+
+ Day with highest check-in activity
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/dash/admin/overview/GenderDistributionChart.tsx b/apps/web/src/components/dash/admin/overview/GenderDistributionChart.tsx
new file mode 100644
index 00000000..015eb7a1
--- /dev/null
+++ b/apps/web/src/components/dash/admin/overview/GenderDistributionChart.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import React from "react";
+import { Label, Pie, PieChart, Cell } from "recharts";
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ ChartLegend,
+ ChartLegendContent,
+ ChartConfig,
+} from "@/components/ui/chart";
+
+type GenderData = {
+ gender: string;
+ count: number;
+ fill: string;
+};
+
+type Props = {
+ genderData: GenderData[];
+};
+
+const chartConfig = {
+ male: {
+ label: "Male",
+ color: "hsl(var(--chart-1))",
+ },
+ female: {
+ label: "Female",
+ color: "hsl(var(--chart-2))",
+ },
+ "non-binary": {
+ label: "Non-binary",
+ color: "hsl(var(--chart-3))",
+ },
+ transgender: {
+ label: "Transgender",
+ color: "hsl(var(--chart-4))",
+ },
+ "prefer-not-to-say": {
+ label: "Prefer not to say",
+ color: "hsl(var(--chart-5))",
+ },
+ other: {
+ label: "Other",
+ color: "hsl(var(--chart-6))",
+ },
+} satisfies ChartConfig;
+
+export default function GenderDistributionChart({ genderData }: Props) {
+ // Format gender labels with capitalized first letter and count
+ // Also convert spaces to hyphens to match chartConfig keys
+ const formattedData = genderData.map((item) => ({
+ ...item,
+ // Add a configKey property that uses hyphens instead of spaces
+ configKey: item.gender.replace(/\s+/g, "-"),
+ formattedLabel: `${item.gender.charAt(0).toUpperCase() + item.gender.slice(1)}`,
+ }));
+
+ const total = formattedData.reduce((sum, item) => sum + item.count, 0);
+ return (
+
+
+
+
+
+ }
+ />
+
+
+
+ }
+ layout="horizontal"
+ verticalAlign="bottom"
+ align="center"
+ />
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/dash/admin/overview/KeyMetrics.tsx b/apps/web/src/components/dash/admin/overview/KeyMetrics.tsx
new file mode 100644
index 00000000..9b8aec64
--- /dev/null
+++ b/apps/web/src/components/dash/admin/overview/KeyMetrics.tsx
@@ -0,0 +1,129 @@
+import {
+ getRegistrationsByMonth,
+ getCheckinsByMonth,
+ getRetentionRate,
+ getGrowthRate,
+} from "@/lib/queries/charts";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ UserIcon,
+ CheckCircleIcon,
+ BarChart,
+ ArrowUpIcon,
+ UsersIcon,
+ TrendingUpIcon,
+} from "lucide-react";
+import { Progress } from "@/components/ui/progress";
+
+export default async function KeyMetrics() {
+ // Fetch data in parallel
+ const [monthlyRegistrations, monthlyCheckins, retentionRate, growthRate] =
+ await Promise.all([
+ getRegistrationsByMonth(),
+ getCheckinsByMonth(),
+ getRetentionRate(),
+ getGrowthRate(),
+ ]);
+
+ // Calculate metrics
+ const totalRegistrations = monthlyRegistrations.reduce(
+ (acc, curr) => acc + curr.count,
+ 0,
+ );
+
+ const totalCheckins = monthlyCheckins.reduce(
+ (acc, curr) => acc + curr.count,
+ 0,
+ );
+
+ // Get new members this month
+ const newMembersThisMonth =
+ monthlyRegistrations[new Date().getMonth()]?.count || 0;
+
+ return (
+
+
+
+
+ Total Registrations
+
+
+
+
+
+ {totalRegistrations}
+
+ This year
+
+
+
+
+
+ Total Check-ins
+
+
+
+
+
+ {totalCheckins}
+
+ This year
+
+
+
+
+
+ New Members
+
+
+
+
+
+ {newMembersThisMonth}
+
+ This month
+
+
+
+
+
+ Retention Rate
+
+
+
+
+
+
+
+ Last 3 months
+
+
+
+
+
+
+ Growth Rate
+
+
+
+
+
+
+ vs last month
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/dash/admin/overview/MembershipStatusChart.tsx b/apps/web/src/components/dash/admin/overview/MembershipStatusChart.tsx
new file mode 100644
index 00000000..bafaf197
--- /dev/null
+++ b/apps/web/src/components/dash/admin/overview/MembershipStatusChart.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import React from "react";
+import { Progress } from "@/components/ui/progress";
+
+type StatusData = {
+ status: string;
+ count: number;
+ color: string;
+};
+
+type Props = {
+ membershipStatus: StatusData[];
+};
+
+export default function MembershipStatusChart({ membershipStatus }: Props) {
+ // Calculate total for percentages
+ const totalMembers = membershipStatus.reduce(
+ (acc, curr) => acc + curr.count,
+ 0,
+ );
+
+ return (
+
+ {membershipStatus.map((status) => (
+
+
+
+ {status.status}
+
+
+ {status.count} (
+ {Math.round((status.count / totalMembers) * 100)}%)
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx b/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx
new file mode 100644
index 00000000..e0ec3701
--- /dev/null
+++ b/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx
@@ -0,0 +1,51 @@
+import { Suspense } from "react";
+import {
+ getRegistrationsByMonth,
+ getCheckinsByMonth,
+} from "@/lib/queries/charts";
+import MonthlyRegistrationChart from "./MonthlyRegistrationChart";
+import MonthlyCheckinChart from "./MonthlyCheckinChart";
+import { ChartSkeleton } from "@/components/ui/skeleton-loaders";
+
+export default async function MembershipTrends() {
+ return (
+
+
+
+ Membership Trends
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+ }
+ >
+
+
+
+
+ );
+}
+
+// Split into separate components so each can fetch its own data independently
+async function RegistrationChartWithData() {
+ const registrations = await getRegistrationsByMonth();
+ return