From a9c016b00d56d86e3d5238c2bb4afb20d528cee9 Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Mon, 24 Feb 2025 10:15:02 -0600 Subject: [PATCH 01/15] Improve registration chart --- apps/web/src/app/admin/members/columns.tsx | 8 ++++++++ .../admin/overview/MonthlyRegistrationChart.tsx | 3 ++- apps/web/src/lib/queries/charts.ts | 17 +++++++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/admin/members/columns.tsx b/apps/web/src/app/admin/members/columns.tsx index 3a9e5dec..7f1ed5a3 100644 --- a/apps/web/src/app/admin/members/columns.tsx +++ b/apps/web/src/app/admin/members/columns.tsx @@ -125,6 +125,14 @@ export const columns: ColumnDef[] = [ return ; }, }, + { + accessorKey: "user.joinDate", + id: "joinDate", + header: ({ column }) => { + return ; + }, + cell: (cellData) => timeCell(cellData), + }, { id: "actions", enablePinning: true, diff --git a/apps/web/src/components/dash/admin/overview/MonthlyRegistrationChart.tsx b/apps/web/src/components/dash/admin/overview/MonthlyRegistrationChart.tsx index b4ca4939..3c55c009 100644 --- a/apps/web/src/components/dash/admin/overview/MonthlyRegistrationChart.tsx +++ b/apps/web/src/components/dash/admin/overview/MonthlyRegistrationChart.tsx @@ -69,7 +69,8 @@ async function MonthlyRegistrationChart({ registrations }: Props) { dataKey="month" tickLine={false} axisLine={false} - tickMargin={4} + padding={{ left: 25, right: 25 }} + interval={0} tickFormatter={(value) => monthList[value - 1].slice(0, 3) } diff --git a/apps/web/src/lib/queries/charts.ts b/apps/web/src/lib/queries/charts.ts index ea57c8aa..2e312679 100644 --- a/apps/web/src/lib/queries/charts.ts +++ b/apps/web/src/lib/queries/charts.ts @@ -2,17 +2,30 @@ import { db, count, sql } from "db"; import { data, users } from "db/schema"; export async function getRegistrationsByMonth() { - return await db + const monthlyRegistrations = await db .select({ month: sql`EXTRACT(MONTH FROM ${users.joinDate})`.mapWith(Number), count: count(), }) .from(users) .where( - sql`${users.joinDate} > NOW() - INTERVAL '1 year' AND ${users.joinDate} < NOW()`, + sql`EXTRACT(YEAR FROM ${users.joinDate}) = EXTRACT(YEAR FROM NOW()) AND ${users.joinDate} < NOW()`, ) .groupBy(sql`EXTRACT(MONTH FROM ${users.joinDate})`) .orderBy(sql`EXTRACT(MONTH FROM ${users.joinDate})`); + + // Create array of all months with count 0 + const allMonths = Array.from({ length: 12 }, (_, i) => ({ + month: i + 1, + count: 0, + })); + + // Merge in actual registration counts + monthlyRegistrations.forEach((reg) => { + allMonths[reg.month - 1].count = reg.count; + }); + + return allMonths; } export async function getUserClassifications() { From b2fbea39ce93b4f02bb850f8f90ae2ad5836b513 Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Tue, 25 Feb 2025 12:52:32 -0600 Subject: [PATCH 02/15] add monthly checkin chart --- apps/web/src/app/admin/page.tsx | 16 +++- .../dash/admin/members/MemberStatsSheet.tsx | 58 +++++++----- .../admin/overview/MonthlyCheckinChart.tsx | 90 +++++++++++++++++++ .../dash/shared/CheckinStatsSheet.tsx | 14 --- apps/web/src/lib/queries/charts.ts | 34 ++++++- 5 files changed, 172 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/dash/admin/overview/MonthlyCheckinChart.tsx diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index dd1bdac8..47e5caf1 100644 --- a/apps/web/src/app/admin/page.tsx +++ b/apps/web/src/app/admin/page.tsx @@ -1,14 +1,17 @@ import DemographicsStats from "@/components/dash/admin/overview/DemographicsStats"; import MonthlyRegistrationChart from "@/components/dash/admin/overview/MonthlyRegistrationChart"; +import MonthlyCheckinChart from "@/components/dash/admin/overview/MonthlyCheckinChart"; import { Separator } from "@/components/ui/separator"; import { getRegistrationsByMonth, getUserClassifications, + getCheckinsByMonth, } from "@/lib/queries/charts"; import { Suspense } from "react"; export default async function Page() { const monthlyRegistrations = await getRegistrationsByMonth(); + const monthlyCheckins = await getCheckinsByMonth(); const classifications = await getUserClassifications(); return (
@@ -17,7 +20,7 @@ export default async function Page() { Trends
-
+
+
+ + Retrieving checkin chart. One sec... +
+ } + > + + +
diff --git a/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx b/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx index c1611c77..57ef8baf 100644 --- a/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx +++ b/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx @@ -1,37 +1,47 @@ import React from "react"; import { getMemberStatsOverview } from "@/lib/queries/users"; - import { Separator } from "@/components/ui/separator"; +type StatItemProps = { + label: string; + value: number | string; +}; + +function StatItem({ label, value }: StatItemProps) { + return ( +
+ {label} + {value} +
+ ); +} + type Props = {}; async function MemberStatsSheet({}: Props) { const stats = await getMemberStatsOverview(); + + const statItems: StatItemProps[] = [ + { + label: "Total Members", + value: stats.totalMembers, + }, + { + label: "Active Members", + value: stats.activeMembers, + }, + ]; + return (
-
- - Total Members - - - {stats.totalMembers} - -
- - {/* Put recent registration count here */} - {/*
- This Week - {stats.thisWeek} -
- */} -
- - Active Members - - - {stats.activeMembers} - -
+ {statItems.map((stat, index) => ( + + + {index < statItems.length - 1 && ( + + )} + + ))}
); } diff --git a/apps/web/src/components/dash/admin/overview/MonthlyCheckinChart.tsx b/apps/web/src/components/dash/admin/overview/MonthlyCheckinChart.tsx new file mode 100644 index 00000000..acc16250 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/MonthlyCheckinChart.tsx @@ -0,0 +1,90 @@ +"use client"; + +import React from "react"; + +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; + +type Props = { + checkins: { + month: number; + count: number; + }[]; +}; + +const chartConfig = { + numCheckins: { + label: "Check-ins", + color: "hsl(var(--chart-2))", + }, +} satisfies ChartConfig; + +const monthList = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +function MonthlyCheckinChart({ checkins }: Props) { + return ( + + + Check-ins by Month + + Showing check-in activity over the last year + + + + + + + + monthList[value - 1].slice(0, 3) + } + /> + } + /> + + + + + + ); +} + +export default MonthlyCheckinChart; diff --git a/apps/web/src/components/dash/shared/CheckinStatsSheet.tsx b/apps/web/src/components/dash/shared/CheckinStatsSheet.tsx index 24edacae..cb7fee99 100644 --- a/apps/web/src/components/dash/shared/CheckinStatsSheet.tsx +++ b/apps/web/src/components/dash/shared/CheckinStatsSheet.tsx @@ -16,20 +16,6 @@ async function CheckinStatsSheet({}: Props) { {stats.total_checkins}
- {/* -
- This Week - {stats.thisWeek} -
- -
- - Past Events - - - {stats.pastEvents} - -
*/}
); } diff --git a/apps/web/src/lib/queries/charts.ts b/apps/web/src/lib/queries/charts.ts index 2e312679..dbfb712f 100644 --- a/apps/web/src/lib/queries/charts.ts +++ b/apps/web/src/lib/queries/charts.ts @@ -1,5 +1,5 @@ import { db, count, sql } from "db"; -import { data, users } from "db/schema"; +import { data, users, checkins } from "db/schema"; export async function getRegistrationsByMonth() { const monthlyRegistrations = await db @@ -28,6 +28,38 @@ export async function getRegistrationsByMonth() { return allMonths; } +// Checkins by month +export async function getCheckinsByMonth() { + const monthlyCheckins = await db + .select({ + month: sql`EXTRACT(MONTH FROM ${checkins.time})`.mapWith(Number), + year: sql`EXTRACT(YEAR FROM ${checkins.time})`.mapWith(Number), + count: count(), + }) + .from(checkins) + .where( + sql`EXTRACT(YEAR FROM ${checkins.time}) = EXTRACT(YEAR FROM NOW()) AND ${checkins.time} < NOW()`, + ) + .groupBy( + sql`EXTRACT(MONTH FROM ${checkins.time})`, + sql`EXTRACT(YEAR FROM ${checkins.time})`, + ) + .orderBy(sql`EXTRACT(MONTH FROM ${checkins.time})`); + + // Create array of all months with count 0 + const allMonths = Array.from({ length: 12 }, (_, i) => ({ + month: i + 1, + count: 0, + })); + + // Merge in actual checkin counts + monthlyCheckins.forEach((checkin) => { + allMonths[checkin.month - 1].count = checkin.count; + }); + + return allMonths; +} + export async function getUserClassifications() { return await db .select({ From 04fb3f681582d680a400156daacf5cfab2f7ad55 Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Tue, 25 Feb 2025 19:48:21 -0600 Subject: [PATCH 03/15] upgrade charts --- apps/web/package.json | 1 + apps/web/src/app/admin/page.tsx | 398 ++++++++++++++++-- apps/web/src/app/globals.css | 13 + .../admin/overview/ActivityByDayChart.tsx | 48 +++ .../dash/admin/overview/AverageVisitsCard.tsx | 20 + .../dash/admin/overview/DemographicsStats.tsx | 59 ++- .../admin/overview/EngagementMetricsCard.tsx | 30 ++ .../overview/GenderDistributionChart.tsx | 98 +++++ .../admin/overview/MembershipStatusChart.tsx | 44 ++ .../admin/overview/RaceDistributionChart.tsx | 111 +++++ .../dash/admin/overview/TimeOfDayChart.tsx | 42 ++ apps/web/src/components/ui/progress.tsx | 28 ++ apps/web/src/lib/queries/charts.ts | 235 ++++++++++- pnpm-lock.yaml | 84 ++++ 14 files changed, 1146 insertions(+), 65 deletions(-) create mode 100644 apps/web/src/components/dash/admin/overview/ActivityByDayChart.tsx create mode 100644 apps/web/src/components/dash/admin/overview/AverageVisitsCard.tsx create mode 100644 apps/web/src/components/dash/admin/overview/EngagementMetricsCard.tsx create mode 100644 apps/web/src/components/dash/admin/overview/GenderDistributionChart.tsx create mode 100644 apps/web/src/components/dash/admin/overview/MembershipStatusChart.tsx create mode 100644 apps/web/src/components/dash/admin/overview/RaceDistributionChart.tsx create mode 100644 apps/web/src/components/dash/admin/overview/TimeOfDayChart.tsx create mode 100644 apps/web/src/components/ui/progress.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 95cdb5be..ad7871c0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.1.0", diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index 47e5caf1..777e972a 100644 --- a/apps/web/src/app/admin/page.tsx +++ b/apps/web/src/app/admin/page.tsx @@ -1,31 +1,213 @@ import DemographicsStats from "@/components/dash/admin/overview/DemographicsStats"; import MonthlyRegistrationChart from "@/components/dash/admin/overview/MonthlyRegistrationChart"; import MonthlyCheckinChart from "@/components/dash/admin/overview/MonthlyCheckinChart"; +import ActivityByDayChart from "@/components/dash/admin/overview/ActivityByDayChart"; +import TimeOfDayChart from "@/components/dash/admin/overview/TimeOfDayChart"; +import MembershipStatusChart from "@/components/dash/admin/overview/MembershipStatusChart"; +import EngagementMetricsCard from "@/components/dash/admin/overview/EngagementMetricsCard"; +import AverageVisitsCard from "@/components/dash/admin/overview/AverageVisitsCard"; +import GenderDistributionChart from "@/components/dash/admin/overview/GenderDistributionChart"; +import RaceDistributionChart from "@/components/dash/admin/overview/RaceDistributionChart"; import { Separator } from "@/components/ui/separator"; import { getRegistrationsByMonth, getUserClassifications, getCheckinsByMonth, + getActiveMembers, + getRetentionRate, + getGrowthRate, + getMostActiveDay, + getActivityByDayOfWeek, + getActivityByTimeOfDay, + getMembershipStatus, + getGenderDistribution, + getRaceDistribution, } from "@/lib/queries/charts"; import { Suspense } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { + CalendarIcon, + UserIcon, + CheckCircleIcon, + BarChart, + ArrowUpIcon, + UsersIcon, + TrendingUpIcon, +} from "lucide-react"; +import { Progress } from "@/components/ui/progress"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; export default async function Page() { + // Fetch all required data from database const monthlyRegistrations = await getRegistrationsByMonth(); const monthlyCheckins = await getCheckinsByMonth(); const classifications = await getUserClassifications(); + const activeMembers = await getActiveMembers(); + const retentionRate = await getRetentionRate(); + const growthRate = await getGrowthRate(); + const mostActiveDay = await getMostActiveDay(); + const activityByDayOfWeek = await getActivityByDayOfWeek(); + const activityByTimeOfDay = await getActivityByTimeOfDay(); + const membershipStatus = await getMembershipStatus(); + const genderData = await getGenderDistribution(); + const raceData = await getRaceDistribution(); + + // Calculate total registrations this year + const totalRegistrations = monthlyRegistrations.reduce( + (acc, curr) => acc + curr.count, + 0, + ); + + // Calculate total check-ins this year + const totalCheckins = monthlyCheckins.reduce( + (acc, curr) => acc + curr.count, + 0, + ); + + // Calculate average monthly registrations + const avgMonthlyRegistrations = Math.round( + totalRegistrations / + monthlyRegistrations.filter((m) => m.count > 0).length || 0, + ); + + // Calculate new members this month + const newMembersThisMonth = + monthlyRegistrations[new Date().getMonth()]?.count || 0; + + // Calculate average visits per member + const averageVisitsPerMember = ( + totalCheckins / (totalRegistrations || 1) + ).toFixed(1); + + // Create engagement metrics object for components + const engagementMetrics = { + activeMembers, + averageVisitsPerMember, + mostActiveDay, + }; + return ( -
-
-

- Trends +
+
+

+ Admin Overview

+

+ Monitor key club metrics and trends +

+
-
-
+ + {/* Key Metrics Summary Cards */} +
+ + + + Total Registrations + + + + +
+ {totalRegistrations} +
+

+ This year +

+
+
+ + + + Total Check-ins + + + + +
+ {totalCheckins} +
+

+ This year +

+
+
+ + + + New Members + + + + +
+ {newMembersThisMonth} +
+

+ This month +

+
+
+ + + + Retention Rate + + + + +
+
+ {retentionRate}% +
+
+
+ +
+

+ Last 3 months +

+
+
+ + + + Growth Rate + + + + +
+
+ {growthRate}% +
+ +
+

+ vs last month +

+
+
+
+ + {/* Trends Section */} +
+
+

+ Membership Trends +

+
+
- Retrieving registration chart. One sec... +
+ Loading registration data...
} > @@ -33,12 +215,10 @@ export default async function Page() { registrations={monthlyRegistrations} />
-
-
- Retrieving checkin chart. One sec... +
+ Loading check-in data...
} > @@ -46,22 +226,186 @@ export default async function Page() {
-
-
-

- Demographics -

+ + {/* Engagement Metrics */} +
+
+

+ Engagement Metrics +

-
- - Retrieving demographics charts. One sec... -
- } - > - - +
+ + + + Active Members + + + Members who visited in the last 30 days + + + + + + + + + + Avg. Visits per Member + + + Average check-ins per registered member + + + + + + + + + + Most Active Day + + + Day with highest check-in activity + + + + + + +
+
+ + {/* Activity Patterns */} +
+
+

+ Activity Patterns +

+
+
+ + + + Activity by Time of Day + + + When members most frequently visit + + + + + + + + + + Membership Status + + + Distribution of member statuses + + + + + + +
+
+ + {/* Demographics Section */} +
+
+

+ Member Demographics +

+
+ +
+ + + + Member Classification + + + Distribution by member level + + + + + Loading demographic data... +
+ } + > + + + + + + + + + Gender Distribution + + + Breakdown of members by gender + + + + + Loading gender data... +
+ } + > + + + + + + + + + Race/Ethnicity + + + Breakdown of members by race/ethnicity + + + + + Loading race/ethnicity data... +
+ } + > + + + +
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 074c5462..9ecb8f20 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -40,6 +40,7 @@ --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + --chart-6: 280 75% 55%; color-scheme: light; } @@ -79,6 +80,7 @@ --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; + --chart-6: 120 70% 45%; color-scheme: dark; } @@ -129,3 +131,14 @@ box-shadow: inset 0 0 25px 5px #0295ff; } } + + + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} 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) => ( +
+
+
+ {day.day} +
+
+ ))} +
+
+ ); +} 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/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/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/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/RaceDistributionChart.tsx b/apps/web/src/components/dash/admin/overview/RaceDistributionChart.tsx new file mode 100644 index 00000000..1447fb63 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/RaceDistributionChart.tsx @@ -0,0 +1,111 @@ +"use client"; + +import React from "react"; +import { Label, Pie, PieChart, Cell, LabelList } from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartConfig, +} from "@/components/ui/chart"; + +type RaceData = { + race: string; + count: number; + fill: string; +}; + +type Props = { + raceData: RaceData[]; +}; + +// Define the chart configuration +const chartConfig = { + asian: { + label: "Asian", + color: "hsl(var(--chart-1))", + }, + "black-or-african-american": { + label: "Black or African American", + color: "hsl(var(--chart-2))", + }, + "hispanic-or-latino": { + label: "Hispanic or Latino", + color: "hsl(var(--chart-3))", + }, + white: { + label: "White", + color: "hsl(var(--chart-4))", + }, + "american-indian-or-alaska-native": { + label: "American Indian or Alaska Native", + color: "hsl(var(--chart-5))", + }, + "native-hawaiian-or-other-pacific-islander": { + label: "Native Hawaiian or Other Pacific Islander", + color: "hsl(var(--chart-6))", + }, + other: { + label: "Other", + color: "hsl(var(--chart-7))", + }, + "prefer-not-to-say": { + label: "Prefer not to say", + color: "hsl(var(--chart-8))", + }, +} satisfies ChartConfig; + +export default function RaceDistributionChart({ raceData }: Props) { + // Format race labels with capitalized first letter and count + const ethnicityData = raceData.map((item) => ({ + ...item, + configKey: item.race.replace(/\s+/g, "-"), + formattedLabel: + chartConfig[ + (item.race[0] + + item.race + .slice(1) + .replaceAll(/\s+/g, "-")) as keyof typeof chartConfig + ].label, + })); + + const total = ethnicityData.reduce((sum, item) => sum + item.count, 0); + + return ( +
+
+ + + + } + /> + + + {/* + } + layout="horizontal" + verticalAlign="bottom" + align="center" + /> */} + + +
+
+ ); +} diff --git a/apps/web/src/components/dash/admin/overview/TimeOfDayChart.tsx b/apps/web/src/components/dash/admin/overview/TimeOfDayChart.tsx new file mode 100644 index 00000000..52f948e3 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/TimeOfDayChart.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; + +type TimeSlot = { + time: string; + count: number; +}; + +type Props = { + activityByTimeOfDay: TimeSlot[]; +}; + +export default function TimeOfDayChart({ activityByTimeOfDay }: Props) { + // Find the highest count to calculate relative widths + const maxCount = Math.max(...activityByTimeOfDay.map((slot) => slot.count)); + + return ( +
+ {activityByTimeOfDay.map((timeSlot) => ( +
+
+ {timeSlot.time} +
+
+
+
+
+
+
+ {timeSlot.count} +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx new file mode 100644 index 00000000..5c87ea48 --- /dev/null +++ b/apps/web/src/components/ui/progress.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/apps/web/src/lib/queries/charts.ts b/apps/web/src/lib/queries/charts.ts index dbfb712f..d5556593 100644 --- a/apps/web/src/lib/queries/charts.ts +++ b/apps/web/src/lib/queries/charts.ts @@ -1,4 +1,4 @@ -import { db, count, sql } from "db"; +import { db, count, sql, eq, and, gt, lte, gte, desc, asc } from "db"; import { data, users, checkins } from "db/schema"; export async function getRegistrationsByMonth() { @@ -65,23 +65,244 @@ export async function getUserClassifications() { .select({ classification: sql`LOWER(${data.classification})`.mapWith(String), count: count(), - fill: sql`CONCAT('var(--color-',LOWER(${data.classification}),')')`.mapWith( + fill: sql`CONCAT('var(--color-',LOWER(REPLACE(${data.classification}, ' ', '-')),')')`.mapWith( String, ), }) .from(data) - .groupBy(sql`LOWER(${data.classification})`.mapWith(String)); + .groupBy( + sql`LOWER(${data.classification})`, + sql`LOWER(REPLACE(${data.classification}, ' ', '-'))`, + ) + .orderBy(desc(count())); } +// Get gender distribution of members export async function getGenderDistribution() { - return await db + // Query the unnested gender array to count each gender + const result = await db + .select({ + gender: sql`LOWER(unnest(${data.gender}))`.mapWith(String), + fill: sql`CONCAT('var(--color-',LOWER(REPLACE(unnest(${data.gender}), ' ', '-')),')')`.mapWith( + String, + ), + count: count(), + }) + .from(data) + .groupBy( + sql`LOWER(unnest(${data.gender}))`, + sql`LOWER(REPLACE(unnest(${data.gender}), ' ', '-'))`, + ) + .orderBy(desc(count())); + + return result; +} + +// Get race/ethnicity distribution of members +export async function getRaceDistribution() { + const result = await db .select({ - gender: sql`LOWER(${data.gender})`.mapWith(String), + race: sql`LOWER(unnest(${data.ethnicity}))`.mapWith(String), count: count(), - fill: sql`CONCAT('var(--color-',LOWER(${data.gender}),')')`.mapWith( + fill: sql`CONCAT('var(--color-',LOWER(REPLACE(unnest(${data.ethnicity}), ' ', '-')),')')`.mapWith( String, ), }) .from(data) - .groupBy(sql`LOWER(${data.gender})`.mapWith(String)); + .groupBy( + sql`LOWER(unnest(${data.ethnicity}))`, + sql`LOWER(REPLACE(unnest(${data.ethnicity}), ' ', '-'))`, + ) + .orderBy(desc(count())); + + return result.map((item) => ({ + ...item, + race: item.race.toLowerCase(), + })); +} + +// Get active members count (members who checked in at least once in the last 30 days) +export async function getActiveMembers() { + const result = await db + .select({ + activeMembers: count(sql`DISTINCT ${checkins.userID}`), + }) + .from(checkins) + .where(sql`${checkins.time} >= NOW() - INTERVAL '30 days'`); + + return result[0]?.activeMembers || 0; +} + +// Get retention rate (percentage of members who were active last month and remained active this month) +export async function getRetentionRate() { + // Members active last month + const lastMonthActiveUsers = await db + .select({ + userID: checkins.userID, + }) + .from(checkins) + .where( + sql`${checkins.time} >= NOW() - INTERVAL '60 days' AND ${checkins.time} < NOW() - INTERVAL '30 days'`, + ) + .groupBy(checkins.userID); + + // Members active this month + const thisMonthActiveUsers = await db + .select({ + userID: checkins.userID, + }) + .from(checkins) + .where(sql`${checkins.time} >= NOW() - INTERVAL '30 days'`) + .groupBy(checkins.userID); + + const lastMonthUserIds = lastMonthActiveUsers.map((u) => u.userID); + const thisMonthUserIds = thisMonthActiveUsers.map((u) => u.userID); + + // Count users who were active both last month and this month + const retainedUsers = lastMonthUserIds.filter((id) => + thisMonthUserIds.includes(id), + ); + + // Calculate retention rate (handle division by zero) + return lastMonthUserIds.length > 0 + ? Math.round((retainedUsers.length / lastMonthUserIds.length) * 100) + : 0; +} + +// Get growth rate compared to previous month +export async function getGrowthRate() { + // Current month signups + const currentMonthResult = await db + .select({ + count: count(), + }) + .from(users) + .where( + sql`${users.joinDate} >= DATE_TRUNC('month', NOW()) AND ${users.joinDate} < NOW()`, + ); + + // Previous month signups + const previousMonthResult = await db + .select({ + count: count(), + }) + .from(users) + .where( + sql`${users.joinDate} >= DATE_TRUNC('month', NOW() - INTERVAL '1 month') AND ${users.joinDate} < DATE_TRUNC('month', NOW())`, + ); + + const currentMonthCount = currentMonthResult[0]?.count || 0; + const previousMonthCount = previousMonthResult[0]?.count || 1; // Avoid division by zero + + return parseFloat( + ( + ((currentMonthCount - previousMonthCount) / previousMonthCount) * + 100 + ).toFixed(1), + ); +} + +// Get activity by day of week +export async function getActivityByDayOfWeek() { + const result = await db + .select({ + day: sql`TO_CHAR(${checkins.time}, 'Dy')`.mapWith(String), + count: count(), + }) + .from(checkins) + .where(sql`${checkins.time} >= NOW() - INTERVAL '90 days'`) + .groupBy( + sql`TO_CHAR(${checkins.time}, 'Dy')`, + sql`TO_CHAR(${checkins.time}, 'ID')`, + ) + .orderBy(sql`TO_CHAR(${checkins.time}, 'ID')`); // Order by day number (Mon=1, Sun=7) + + return result; +} + +// Get most active day +export async function getMostActiveDay() { + const result = await db + .select({ + day: sql`TO_CHAR(${checkins.time}, 'Day')`.mapWith(String), + count: count(), + }) + .from(checkins) + .where(sql`${checkins.time} >= NOW() - INTERVAL '90 days'`) + .groupBy(sql`TO_CHAR(${checkins.time}, 'Day')`) + .orderBy(desc(count())) + .limit(1); + + return result[0]?.day.trim() || "Wednesday"; +} + +// Get activity by time of day +export async function getActivityByTimeOfDay() { + const timeSlots = [ + { slot: "6-9 AM", start: 6, end: 9 }, + { slot: "9-12 PM", start: 9, end: 12 }, + { slot: "12-3 PM", start: 12, end: 15 }, + { slot: "3-6 PM", start: 15, end: 18 }, + { slot: "6-9 PM", start: 18, end: 21 }, + { slot: "9-12 AM", start: 21, end: 24 }, + ]; + + const results = []; + + // Run query for each time slot + for (const slot of timeSlots) { + const result = await db + .select({ + count: count(), + }) + .from(checkins) + .where( + sql`${checkins.time} >= NOW() - INTERVAL '90 days' AND EXTRACT(HOUR FROM ${checkins.time}) >= ${slot.start} AND EXTRACT(HOUR FROM ${checkins.time}) < ${slot.end}`, + ); + + results.push({ + time: slot.slot, + count: result[0]?.count || 0, + }); + } + + return results; +} + +// Get membership status distribution +export async function getMembershipStatus() { + const statusColors: Record = { + Active: "bg-green-500", + Trial: "bg-blue-500", + Expired: "bg-amber-500", + Inactive: "bg-red-500", + }; + + // Since we don't have a memberships table, we'll use the users table + // and derive status from joinDate for demonstration purposes + const result = await db + .select({ + status: sql`CASE + WHEN ${users.joinDate} >= NOW() - INTERVAL '7 days' THEN 'Trial' + WHEN ${users.joinDate} >= NOW() - INTERVAL '90 days' THEN 'Active' + WHEN ${users.joinDate} >= NOW() - INTERVAL '180 days' THEN 'Expired' + ELSE 'Inactive' + END`.mapWith(String), + count: count(), + }) + .from(users) + .groupBy( + sql`CASE + WHEN ${users.joinDate} >= NOW() - INTERVAL '7 days' THEN 'Trial' + WHEN ${users.joinDate} >= NOW() - INTERVAL '90 days' THEN 'Active' + WHEN ${users.joinDate} >= NOW() - INTERVAL '180 days' THEN 'Expired' + ELSE 'Inactive' + END`, + ) + .orderBy(desc(count())); + + return result.map((item) => ({ + ...item, + color: statusColors[item.status] || "bg-gray-500", + })); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a2dec62..86889be0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-progress': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-scroll-area': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0) @@ -1639,6 +1642,19 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-compose-refs@1.1.1(@types/react@18.2.61)(react@18.2.0): + resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.61 + react: 18.2.0 + dev: false + /@radix-ui/react-context@1.0.1(@types/react@18.2.61)(react@18.2.0): resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} peerDependencies: @@ -1666,6 +1682,19 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-context@1.1.1(@types/react@18.2.61)(react@18.2.0): + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.61 + react: 18.2.0 + dev: false + /@radix-ui/react-dialog@1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==} peerDependencies: @@ -2100,6 +2129,47 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-primitive@2.0.2(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-slot': 1.1.2(@types/react@18.2.61)(react@18.2.0) + '@types/react': 18.2.61 + '@types/react-dom': 18.2.19 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-progress@1.1.2(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-context': 1.1.1(@types/react@18.2.61)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.61 + '@types/react-dom': 18.2.19 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} peerDependencies: @@ -2276,6 +2346,20 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-slot@1.1.2(@types/react@18.2.61)(react@18.2.0): + resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.61)(react@18.2.0) + '@types/react': 18.2.61 + react: 18.2.0 + dev: false + /@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==} peerDependencies: From 3df9f9627b735366aaf55b327fd669981127fca2 Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Wed, 26 Feb 2025 08:56:55 -0600 Subject: [PATCH 04/15] Improve admin overview load time --- apps/web/src/app/admin/page.tsx | 441 +++--------------- .../dash/admin/overview/ActivityPatterns.tsx | 108 +++++ .../admin/overview/DemographicsSection.tsx | 137 ++++++ .../dash/admin/overview/EngagementSection.tsx | 169 +++++++ .../dash/admin/overview/KeyMetrics.tsx | 125 +++++ .../dash/admin/overview/MembershipTrends.tsx | 50 ++ 6 files changed, 643 insertions(+), 387 deletions(-) create mode 100644 apps/web/src/components/dash/admin/overview/ActivityPatterns.tsx create mode 100644 apps/web/src/components/dash/admin/overview/DemographicsSection.tsx create mode 100644 apps/web/src/components/dash/admin/overview/EngagementSection.tsx create mode 100644 apps/web/src/components/dash/admin/overview/KeyMetrics.tsx create mode 100644 apps/web/src/components/dash/admin/overview/MembershipTrends.tsx diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index 777e972a..bd30bc64 100644 --- a/apps/web/src/app/admin/page.tsx +++ b/apps/web/src/app/admin/page.tsx @@ -1,97 +1,14 @@ -import DemographicsStats from "@/components/dash/admin/overview/DemographicsStats"; -import MonthlyRegistrationChart from "@/components/dash/admin/overview/MonthlyRegistrationChart"; -import MonthlyCheckinChart from "@/components/dash/admin/overview/MonthlyCheckinChart"; -import ActivityByDayChart from "@/components/dash/admin/overview/ActivityByDayChart"; -import TimeOfDayChart from "@/components/dash/admin/overview/TimeOfDayChart"; -import MembershipStatusChart from "@/components/dash/admin/overview/MembershipStatusChart"; -import EngagementMetricsCard from "@/components/dash/admin/overview/EngagementMetricsCard"; -import AverageVisitsCard from "@/components/dash/admin/overview/AverageVisitsCard"; -import GenderDistributionChart from "@/components/dash/admin/overview/GenderDistributionChart"; -import RaceDistributionChart from "@/components/dash/admin/overview/RaceDistributionChart"; -import { Separator } from "@/components/ui/separator"; -import { - getRegistrationsByMonth, - getUserClassifications, - getCheckinsByMonth, - getActiveMembers, - getRetentionRate, - getGrowthRate, - getMostActiveDay, - getActivityByDayOfWeek, - getActivityByTimeOfDay, - getMembershipStatus, - getGenderDistribution, - getRaceDistribution, -} from "@/lib/queries/charts"; import { Suspense } from "react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, -} from "@/components/ui/card"; -import { - CalendarIcon, - UserIcon, - CheckCircleIcon, - BarChart, - ArrowUpIcon, - UsersIcon, - TrendingUpIcon, -} from "lucide-react"; -import { Progress } from "@/components/ui/progress"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; - +import { Separator } from "@/components/ui/separator"; +import KeyMetrics from "@/components/dash/admin/overview/KeyMetrics"; +import MembershipTrends from "@/components/dash/admin/overview/MembershipTrends"; +import EngagementSection from "@/components/dash/admin/overview/EngagementSection"; +import ActivityPatterns from "@/components/dash/admin/overview/ActivityPatterns"; +import DemographicsSection from "@/components/dash/admin/overview/DemographicsSection"; + +// The dashboard always fetches fresh data on each request +// Data is memoized within each render via React's built-in request deduplication export default async function Page() { - // Fetch all required data from database - const monthlyRegistrations = await getRegistrationsByMonth(); - const monthlyCheckins = await getCheckinsByMonth(); - const classifications = await getUserClassifications(); - const activeMembers = await getActiveMembers(); - const retentionRate = await getRetentionRate(); - const growthRate = await getGrowthRate(); - const mostActiveDay = await getMostActiveDay(); - const activityByDayOfWeek = await getActivityByDayOfWeek(); - const activityByTimeOfDay = await getActivityByTimeOfDay(); - const membershipStatus = await getMembershipStatus(); - const genderData = await getGenderDistribution(); - const raceData = await getRaceDistribution(); - - // Calculate total registrations this year - const totalRegistrations = monthlyRegistrations.reduce( - (acc, curr) => acc + curr.count, - 0, - ); - - // Calculate total check-ins this year - const totalCheckins = monthlyCheckins.reduce( - (acc, curr) => acc + curr.count, - 0, - ); - - // Calculate average monthly registrations - const avgMonthlyRegistrations = Math.round( - totalRegistrations / - monthlyRegistrations.filter((m) => m.count > 0).length || 0, - ); - - // Calculate new members this month - const newMembersThisMonth = - monthlyRegistrations[new Date().getMonth()]?.count || 0; - - // Calculate average visits per member - const averageVisitsPerMember = ( - totalCheckins / (totalRegistrations || 1) - ).toFixed(1); - - // Create engagement metrics object for components - const engagementMetrics = { - activeMembers, - averageVisitsPerMember, - mostActiveDay, - }; - return (
@@ -105,309 +22,59 @@ export default async function Page() {
{/* Key Metrics Summary Cards */} -
- - - - Total Registrations - - - - -
- {totalRegistrations} -
-

- This year -

-
-
- - - - Total Check-ins - - - - -
- {totalCheckins} -
-

- This year -

-
-
- - - - New Members - - - - -
- {newMembersThisMonth} -
-

- This month -

-
-
- - - - Retention Rate - - - - -
-
- {retentionRate}% -
-
-
- -
-

- Last 3 months -

-
-
- - - - Growth Rate - - - - -
-
- {growthRate}% -
- -
-

- vs last month -

-
-
-
+ + Loading key metrics... +
+ } + > + + {/* Trends Section */} -
-
-

- Membership Trends -

-
-
- - Loading registration data... -
- } - > - - - - Loading check-in data... -
- } - > - - -
-

+ + Loading membership trends... +
+ } + > + + {/* Engagement Metrics */} -
-
-

- Engagement Metrics -

-
-
- - - - Active Members - - - Members who visited in the last 30 days - - - - - - - - - - Avg. Visits per Member - - - Average check-ins per registered member - - - - - - - - - - Most Active Day - - - Day with highest check-in activity - - - - - - -
-
+ + Loading engagement metrics... + + } + > + + {/* Activity Patterns */} -
-
-

- Activity Patterns -

-
-
- - - - Activity by Time of Day - - - When members most frequently visit - - - - - - - - - - Membership Status - - - Distribution of member statuses - - - - - - -
-
+ + Loading activity patterns... + + } + > + + {/* Demographics Section */} -
-
-

- Member Demographics -

-
- -
- - - - Member Classification - - - Distribution by member level - - - - - Loading demographic data... -
- } - > - - - - - - - - - Gender Distribution - - - Breakdown of members by gender - - - - - Loading gender data... -
- } - > - - - - - - - - - Race/Ethnicity - - - Breakdown of members by race/ethnicity - - - - - Loading race/ethnicity data... - - } - > - - - - - - + + Loading demographics data... + + } + > + + ); } 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..488534a2 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/ActivityPatterns.tsx @@ -0,0 +1,108 @@ +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/DemographicsSection.tsx b/apps/web/src/components/dash/admin/overview/DemographicsSection.tsx new file mode 100644 index 00000000..300b2e9b --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/DemographicsSection.tsx @@ -0,0 +1,137 @@ +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/EngagementSection.tsx b/apps/web/src/components/dash/admin/overview/EngagementSection.tsx new file mode 100644 index 00000000..4d1fb7d0 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/EngagementSection.tsx @@ -0,0 +1,169 @@ +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/KeyMetrics.tsx b/apps/web/src/components/dash/admin/overview/KeyMetrics.tsx new file mode 100644 index 00000000..ab4c183d --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/KeyMetrics.tsx @@ -0,0 +1,125 @@ +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 + + + + +
+
+ {retentionRate}% +
+
+
+ +
+

+ Last 3 months +

+
+
+ + + + Growth Rate + + + + +
+
{growthRate}%
+ +
+

+ vs last month +

+
+
+
+ ); +} 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..72f33a44 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx @@ -0,0 +1,50 @@ +import { Suspense } from "react"; +import { + getRegistrationsByMonth, + getCheckinsByMonth, +} from "@/lib/queries/charts"; +import MonthlyRegistrationChart from "./MonthlyRegistrationChart"; +import MonthlyCheckinChart from "./MonthlyCheckinChart"; + +export default async function MembershipTrends() { + return ( +
+
+

+ Membership Trends +

+
+
+ + Loading registration data... +
+ } + > + + + + Loading check-in data... +
+ } + > + + + + + ); +} + +// Split into separate components so each can fetch its own data independently +async function RegistrationChartWithData() { + const registrations = await getRegistrationsByMonth(); + return ; +} + +async function CheckinChartWithData() { + const checkins = await getCheckinsByMonth(); + return ; +} From 2128b67c6370c9a0f9b4a544805d322600b8d0f1 Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Wed, 26 Feb 2025 09:18:24 -0600 Subject: [PATCH 05/15] Mobile optimization --- apps/web/src/app/admin/page.tsx | 20 ++++---- .../dash/admin/overview/ActivityPatterns.tsx | 16 ++++--- .../admin/overview/DemographicsSection.tsx | 16 ++++--- .../dash/admin/overview/EngagementSection.tsx | 16 ++++--- .../dash/admin/overview/KeyMetrics.tsx | 48 ++++++++++--------- .../dash/admin/overview/MembershipTrends.tsx | 10 ++-- 6 files changed, 68 insertions(+), 58 deletions(-) diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index bd30bc64..87554e8d 100644 --- a/apps/web/src/app/admin/page.tsx +++ b/apps/web/src/app/admin/page.tsx @@ -10,21 +10,21 @@ import DemographicsSection from "@/components/dash/admin/overview/DemographicsSe // Data is memoized within each render via React's built-in request deduplication export default async function Page() { return ( -
-
-

+
+
+

Admin Overview

-

+

Monitor key club metrics and trends

- +
{/* Key Metrics Summary Cards */} +
Loading key metrics...
} @@ -35,7 +35,7 @@ export default async function Page() { {/* Trends Section */} +
Loading membership trends...
} @@ -46,7 +46,7 @@ export default async function Page() { {/* Engagement Metrics */} +
Loading engagement metrics...
} @@ -57,7 +57,7 @@ export default async function Page() { {/* Activity Patterns */} +
Loading activity patterns...
} @@ -68,7 +68,7 @@ export default async function Page() { {/* Demographics Section */} +
Loading demographics data...
} diff --git a/apps/web/src/components/dash/admin/overview/ActivityPatterns.tsx b/apps/web/src/components/dash/admin/overview/ActivityPatterns.tsx index 488534a2..8c0bfc47 100644 --- a/apps/web/src/components/dash/admin/overview/ActivityPatterns.tsx +++ b/apps/web/src/components/dash/admin/overview/ActivityPatterns.tsx @@ -15,13 +15,13 @@ import { export default function ActivityPatterns() { return ( -
+
-

+

Activity Patterns

-
+
- - {title} - {description} + + {title} + + {description} + -
+
Loading...
diff --git a/apps/web/src/components/dash/admin/overview/DemographicsSection.tsx b/apps/web/src/components/dash/admin/overview/DemographicsSection.tsx index 300b2e9b..73b24828 100644 --- a/apps/web/src/components/dash/admin/overview/DemographicsSection.tsx +++ b/apps/web/src/components/dash/admin/overview/DemographicsSection.tsx @@ -17,14 +17,14 @@ import { export default function DemographicsSection() { return ( -
+
-

+

Member Demographics

-
+
- - {title} - {description} + + {title} + + {description} + -
+
Loading...
diff --git a/apps/web/src/components/dash/admin/overview/EngagementSection.tsx b/apps/web/src/components/dash/admin/overview/EngagementSection.tsx index 4d1fb7d0..40e99cf4 100644 --- a/apps/web/src/components/dash/admin/overview/EngagementSection.tsx +++ b/apps/web/src/components/dash/admin/overview/EngagementSection.tsx @@ -19,13 +19,13 @@ import { export default function EngagementSection() { return ( -
+
-

+

Engagement Metrics

-
+
- - {title} - {description} + + {title} + + {description} + -
+
Loading...
diff --git a/apps/web/src/components/dash/admin/overview/KeyMetrics.tsx b/apps/web/src/components/dash/admin/overview/KeyMetrics.tsx index ab4c183d..9b8aec64 100644 --- a/apps/web/src/components/dash/admin/overview/KeyMetrics.tsx +++ b/apps/web/src/components/dash/admin/overview/KeyMetrics.tsx @@ -41,57 +41,59 @@ export default async function KeyMetrics() { monthlyRegistrations[new Date().getMonth()]?.count || 0; return ( -
+
- - + + Total Registrations - + -
+
{totalRegistrations}

This year

- - + + Total Check-ins - + -
{totalCheckins}
+
+ {totalCheckins} +

This year

- - + + New Members - + -
+
{newMembersThisMonth}

This month

- - + + Retention Rate - +
-
+
{retentionRate}%
@@ -104,16 +106,18 @@ export default async function KeyMetrics() { - - + + Growth Rate - +
-
{growthRate}%
- +
+ {growthRate}% +
+

vs last month diff --git a/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx b/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx index 72f33a44..d7b3f330 100644 --- a/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx +++ b/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx @@ -8,16 +8,16 @@ import MonthlyCheckinChart from "./MonthlyCheckinChart"; export default async function MembershipTrends() { return ( -

+
-

+

Membership Trends

-
+
+
Loading registration data...
} @@ -26,7 +26,7 @@ export default async function MembershipTrends() {
+
Loading check-in data...
} From 0314fbf0440858babaa3578475970e6f52e2c88e Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Wed, 26 Feb 2025 09:27:46 -0600 Subject: [PATCH 06/15] Fix member table timecell --- apps/web/src/app/admin/members/columns.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/admin/members/columns.tsx b/apps/web/src/app/admin/members/columns.tsx index 7f1ed5a3..cd9e2a31 100644 --- a/apps/web/src/app/admin/members/columns.tsx +++ b/apps/web/src/app/admin/members/columns.tsx @@ -22,7 +22,10 @@ import UpdateRoleDialogue from "@/components/dash/shared/UpdateRoleDialogue"; const timeFormatString = "eee, MMM dd yyyy HH:mm bb"; const timeCell = ({ row }: { row: Row }) => { - const formattedDate = formatDate(row.getValue(""), timeFormatString); + const formattedDate = formatDate( + row.getValue("joinDate"), + timeFormatString, + ); return
{formattedDate}
; }; @@ -131,7 +134,7 @@ export const columns: ColumnDef[] = [ header: ({ column }) => { return ; }, - cell: (cellData) => timeCell(cellData), + cell: ({ row }) => timeCell({ row }), }, { id: "actions", From 0f0e3abd57e7bf3a08f3317b6e4fe49f826cf5b1 Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Wed, 26 Feb 2025 09:58:34 -0600 Subject: [PATCH 07/15] Stat sheet refactor --- .../dash/admin/events/EventStatsSheet.tsx | 47 ++++++++----------- .../dash/admin/members/MemberStatsSheet.tsx | 32 ++----------- .../dash/shared/CheckinStatsSheet.tsx | 24 +++++----- .../src/components/dash/shared/StatItem.tsx | 20 ++++++++ .../src/components/dash/shared/StatsSheet.tsx | 30 ++++++++++++ 5 files changed, 84 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/components/dash/shared/StatItem.tsx create mode 100644 apps/web/src/components/dash/shared/StatsSheet.tsx 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 57ef8baf..3f0d2207 100644 --- a/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx +++ b/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx @@ -1,20 +1,7 @@ import React from "react"; import { getMemberStatsOverview } from "@/lib/queries/users"; -import { Separator } from "@/components/ui/separator"; - -type StatItemProps = { - label: string; - value: number | string; -}; - -function StatItem({ label, value }: StatItemProps) { - return ( -
- {label} - {value} -
- ); -} +import { StatItemProps } from "@/components/dash/shared/StatItem"; +import StatsSheet from "@/components/dash/shared/StatsSheet"; type Props = {}; @@ -27,23 +14,12 @@ async function MemberStatsSheet({}: Props) { value: stats.totalMembers, }, { - label: "Active Members", + label: "Active Members", value: stats.activeMembers, }, ]; - return ( -
- {statItems.map((stat, index) => ( - - - {index < statItems.length - 1 && ( - - )} - - ))} -
- ); + return ; } export default MemberStatsSheet; diff --git a/apps/web/src/components/dash/shared/CheckinStatsSheet.tsx b/apps/web/src/components/dash/shared/CheckinStatsSheet.tsx index cb7fee99..6d642e61 100644 --- a/apps/web/src/components/dash/shared/CheckinStatsSheet.tsx +++ b/apps/web/src/components/dash/shared/CheckinStatsSheet.tsx @@ -1,23 +1,21 @@ import React from "react"; -import { Separator } from "@/components/ui/separator"; import { getCheckinStatsOverview } from "@/lib/queries/checkins"; +import { StatItemProps } from "@/components/dash/shared/StatItem"; +import StatsSheet from "@/components/dash/shared/StatsSheet"; type Props = {}; async function CheckinStatsSheet({}: Props) { const stats = await getCheckinStatsOverview(); - return ( -
-
- - Total Checkins - - - {stats.total_checkins} - -
-
- ); + + const statItems: StatItemProps[] = [ + { + label: "Total Checkins", + value: stats.total_checkins, + }, + ]; + + return ; } export default CheckinStatsSheet; diff --git a/apps/web/src/components/dash/shared/StatItem.tsx b/apps/web/src/components/dash/shared/StatItem.tsx new file mode 100644 index 00000000..12a0190c --- /dev/null +++ b/apps/web/src/components/dash/shared/StatItem.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +export type StatItemProps = { + label: string; + value: number | string; +}; + +/** + * Reusable component for displaying a single statistic with a label and value + */ +function StatItem({ label, value }: StatItemProps) { + return ( +
+ {label} + {value} +
+ ); +} + +export default StatItem; diff --git a/apps/web/src/components/dash/shared/StatsSheet.tsx b/apps/web/src/components/dash/shared/StatsSheet.tsx new file mode 100644 index 00000000..155bbd4c --- /dev/null +++ b/apps/web/src/components/dash/shared/StatsSheet.tsx @@ -0,0 +1,30 @@ +import React, { ReactNode } from "react"; +import { Separator } from "@/components/ui/separator"; +import StatItem, { StatItemProps } from "./StatItem"; + +type StatsSheetProps = { + items: StatItemProps[]; + className?: string; +}; + +/** + * A component for displaying a collection of statistics with consistent styling + */ +function StatsSheet({ items, className = "" }: StatsSheetProps) { + return ( +
+ {items.map((stat, index) => ( + + + {index < items.length - 1 && ( + + )} + + ))} +
+ ); +} + +export default StatsSheet; From 05753f2e77079fd48245dbf5e59e81b5d6d3636e Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Wed, 26 Feb 2025 10:48:04 -0600 Subject: [PATCH 08/15] improve ui loading and streaming --- apps/web/src/app/admin/categories/loading.tsx | 16 +++ apps/web/src/app/admin/categories/page.tsx | 6 +- apps/web/src/app/admin/layout.tsx | 3 +- apps/web/src/app/admin/loading.tsx | 35 +++++ apps/web/src/app/admin/members/loading.tsx | 19 +++ apps/web/src/app/admin/members/page.tsx | 6 +- apps/web/src/app/admin/page.tsx | 46 ++----- .../src/app/events/[slug]/checkin/loading.tsx | 13 ++ .../src/app/events/[slug]/checkin/page.tsx | 15 +-- apps/web/src/app/events/[slug]/loading.tsx | 48 +++++++ apps/web/src/app/events/loading.tsx | 16 +++ .../dash/admin/overview/MembershipTrends.tsx | 9 +- .../src/components/ui/skeleton-loaders.tsx | 126 ++++++++++++++++++ 13 files changed, 303 insertions(+), 55 deletions(-) create mode 100644 apps/web/src/app/admin/categories/loading.tsx create mode 100644 apps/web/src/app/admin/loading.tsx create mode 100644 apps/web/src/app/admin/members/loading.tsx create mode 100644 apps/web/src/app/events/[slug]/checkin/loading.tsx create mode 100644 apps/web/src/app/events/[slug]/loading.tsx create mode 100644 apps/web/src/app/events/loading.tsx create mode 100644 apps/web/src/components/ui/skeleton-loaders.tsx diff --git a/apps/web/src/app/admin/categories/loading.tsx b/apps/web/src/app/admin/categories/loading.tsx new file mode 100644 index 00000000..71c7c40e --- /dev/null +++ b/apps/web/src/app/admin/categories/loading.tsx @@ -0,0 +1,16 @@ +import { TableSkeleton } from "@/components/ui/skeleton-loaders"; + +export default function Loading() { + return ( +
+
+

+ Categories +

+
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/admin/categories/page.tsx b/apps/web/src/app/admin/categories/page.tsx index a706d16d..6505ce66 100644 --- a/apps/web/src/app/admin/categories/page.tsx +++ b/apps/web/src/app/admin/categories/page.tsx @@ -1,5 +1,5 @@ -import { Suspense } from "react"; import AdminCategoryView from "@/components/dash/admin/categories/CategoryView"; + export default function Page() { return (
@@ -8,9 +8,9 @@ export default function Page() { Categories

- Grabbing checkin stats. One sec...
}> +
- +
); } diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx index d7147386..f5cb9834 100644 --- a/apps/web/src/app/admin/layout.tsx +++ b/apps/web/src/app/admin/layout.tsx @@ -7,7 +7,6 @@ import FullScreenMessage from "@/components/shared/fullscreen-message"; import Navbar from "@/components/shared/navbar"; import DashNavItem from "@/components/dash/shared/DashNavItem"; import ClientToast from "@/components/shared/client-toast"; -import { Suspense } from "react"; import c from "config"; export default async function AdminLayout({ @@ -43,7 +42,7 @@ export default async function AdminLayout({ return ; })} - Loading...

}>{children}
+ {children} ); } diff --git a/apps/web/src/app/admin/loading.tsx b/apps/web/src/app/admin/loading.tsx new file mode 100644 index 00000000..c7503654 --- /dev/null +++ b/apps/web/src/app/admin/loading.tsx @@ -0,0 +1,35 @@ +import { + DashboardSectionSkeleton, + KeyMetricsSkeleton, +} from "@/components/ui/skeleton-loaders"; + +export default function Loading() { + return ( +
+
+

+ Admin Overview +

+

+ Monitor key club metrics and trends +

+
+
+ + {/* Key Metrics Summary Cards */} + + + {/* Trends Section */} + + + {/* Engagement Metrics */} + + + {/* Activity Patterns */} + + + {/* Demographics */} + +
+ ); +} diff --git a/apps/web/src/app/admin/members/loading.tsx b/apps/web/src/app/admin/members/loading.tsx new file mode 100644 index 00000000..2cad03a2 --- /dev/null +++ b/apps/web/src/app/admin/members/loading.tsx @@ -0,0 +1,19 @@ +import { StatsSkeleton, TableSkeleton } from "@/components/ui/skeleton-loaders"; + +export default function Loading() { + return ( +
+
+

+ Members +

+
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/admin/members/page.tsx b/apps/web/src/app/admin/members/page.tsx index 858076dc..4dfeb093 100644 --- a/apps/web/src/app/admin/members/page.tsx +++ b/apps/web/src/app/admin/members/page.tsx @@ -1,7 +1,5 @@ -import { Suspense } from "react"; import { getUserWithData } from "@/lib/queries/users"; import { columns } from "./columns"; - import { DataTable } from "@/components/ui/data-table"; import MemberStatsSheet from "@/components/dash/admin/members/MemberStatsSheet"; @@ -15,9 +13,7 @@ async function Page() {
- ...loading
}> - - + {/*
{events?.[0].name}
*/}
diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index 87554e8d..d04cfd3e 100644 --- a/apps/web/src/app/admin/page.tsx +++ b/apps/web/src/app/admin/page.tsx @@ -5,6 +5,10 @@ import MembershipTrends from "@/components/dash/admin/overview/MembershipTrends" import EngagementSection from "@/components/dash/admin/overview/EngagementSection"; import ActivityPatterns from "@/components/dash/admin/overview/ActivityPatterns"; import DemographicsSection from "@/components/dash/admin/overview/DemographicsSection"; +import { + KeyMetricsSkeleton, + DashboardSectionSkeleton, +} from "@/components/ui/skeleton-loaders"; // The dashboard always fetches fresh data on each request // Data is memoized within each render via React's built-in request deduplication @@ -21,57 +25,35 @@ export default async function Page() {
- {/* Key Metrics Summary Cards */} - - Loading key metrics... - - } - > + {/* Key Metrics Summary Cards with streaming */} + }> - {/* Trends Section */} + {/* Trends Section with streaming */} - Loading membership trends... - - } + fallback={} > - {/* Engagement Metrics */} + {/* Engagement Metrics with streaming */} - Loading engagement metrics... - - } + fallback={} > - {/* Activity Patterns */} + {/* Activity Patterns with streaming */} - Loading activity patterns... - - } + fallback={} > - {/* Demographics Section */} + {/* Demographics with streaming */} - Loading demographics data... - - } + fallback={} > diff --git a/apps/web/src/app/events/[slug]/checkin/loading.tsx b/apps/web/src/app/events/[slug]/checkin/loading.tsx new file mode 100644 index 00000000..e3cd59a0 --- /dev/null +++ b/apps/web/src/app/events/[slug]/checkin/loading.tsx @@ -0,0 +1,13 @@ +import Navbar from "@/components/shared/navbar"; +import { CardSkeleton } from "@/components/ui/skeleton-loaders"; + +export default function Loading() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/apps/web/src/app/events/[slug]/checkin/page.tsx b/apps/web/src/app/events/[slug]/checkin/page.tsx index 18bc9dba..69350dff 100644 --- a/apps/web/src/app/events/[slug]/checkin/page.tsx +++ b/apps/web/src/app/events/[slug]/checkin/page.tsx @@ -3,7 +3,6 @@ import Navbar from "@/components/shared/navbar"; import { auth } from "@clerk/nextjs/server"; import { redirect } from "next/navigation"; import PageError from "@/components/shared/PageError"; -import { Suspense } from "react"; import { getUserCheckin } from "@/lib/queries/users"; import { getUTCDate } from "@/lib/utils"; @@ -26,14 +25,12 @@ export default function Page({ params }: { params: { slug: string } }) { return (
- {" "} - Grabbing the event. One sec...}> - - + +
); } diff --git a/apps/web/src/app/events/[slug]/loading.tsx b/apps/web/src/app/events/[slug]/loading.tsx new file mode 100644 index 00000000..604224f2 --- /dev/null +++ b/apps/web/src/app/events/[slug]/loading.tsx @@ -0,0 +1,48 @@ +import Navbar from "@/components/shared/navbar"; +import { CardSkeleton } from "@/components/ui/skeleton-loaders"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ +
+
+ + +
+ +
+
+ +
+ +
+
+ + + + +
+ +
+ +
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/events/loading.tsx b/apps/web/src/app/events/loading.tsx new file mode 100644 index 00000000..b8d4b37a --- /dev/null +++ b/apps/web/src/app/events/loading.tsx @@ -0,0 +1,16 @@ +import { CardSkeleton } from "@/components/ui/skeleton-loaders"; + +export default function Loading() { + return ( +
+

Events

+
+ {Array(6) + .fill(0) + .map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx b/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx index d7b3f330..e0ec3701 100644 --- a/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx +++ b/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx @@ -5,6 +5,7 @@ import { } 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 ( @@ -17,8 +18,8 @@ export default async function MembershipTrends() {
- Loading registration data... +
+
} > @@ -26,8 +27,8 @@ export default async function MembershipTrends() {
- Loading check-in data... +
+
} > diff --git a/apps/web/src/components/ui/skeleton-loaders.tsx b/apps/web/src/components/ui/skeleton-loaders.tsx new file mode 100644 index 00000000..54a9210d --- /dev/null +++ b/apps/web/src/components/ui/skeleton-loaders.tsx @@ -0,0 +1,126 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +// Basic content skeleton for general use +export function ContentSkeleton() { + return ( +
+ + + + +
+ ); +} + +// Card-style skeleton for dashboard items +export function CardSkeleton() { + return ( +
+ +
+ + + +
+
+ ); +} + +// Table skeleton for data tables +export function TableSkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+
+ {Array(4) + .fill(0) + .map((_, i) => ( + + ))} +
+
+ {Array(rows) + .fill(0) + .map((_, i) => ( +
+ {Array(4) + .fill(0) + .map((_, j) => ( + + ))} +
+ ))} +
+
+ ); +} + +// Stats skeleton for member stats and other metrics +export function StatsSkeleton() { + return ( +
+ {Array(4) + .fill(0) + .map((_, i) => ( +
+ + +
+ ))} +
+ ); +} + +// Chart skeleton for analytics sections +export function ChartSkeleton({ height = "h-64" }: { height?: string }) { + return ( +
+ + +
+ + + + +
+
+ ); +} + +// Key metrics skeleton specifically for dashboard metrics cards +export function KeyMetricsSkeleton() { + return ( +
+ {Array(5) + .fill(0) + .map((_, i) => ( +
+
+ + +
+ + +
+ ))} +
+ ); +} + +// Dashboard section skeleton with title and content area +export function DashboardSectionSkeleton({ + height = "h-64", +}: { + height?: string; +}) { + return ( +
+
+ +
+ +
+ ); +} From 398abb8b8e3347585566fa707f26768e23ffc78d Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Tue, 18 Mar 2025 11:50:00 -0500 Subject: [PATCH 09/15] switch chart queries to libsql --- apps/web/src/lib/queries/charts.ts | 86 ++++++++++++++++-------------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/apps/web/src/lib/queries/charts.ts b/apps/web/src/lib/queries/charts.ts index ee2f017e..a1f59c07 100644 --- a/apps/web/src/lib/queries/charts.ts +++ b/apps/web/src/lib/queries/charts.ts @@ -9,10 +9,10 @@ export async function getRegistrationsByMonth() { }) .from(users) .where( - sql`EXTRACT(YEAR FROM ${users.joinDate}) = EXTRACT(YEAR FROM NOW()) AND ${users.joinDate} < NOW()`, + sql`strftime('%Y', ${users.joinDate}) = strftime('%Y', datetime('now')) AND ${users.joinDate} < datetime('now')`, ) - .groupBy(sql`EXTRACT(MONTH FROM ${users.joinDate})`) - .orderBy(sql`EXTRACT(MONTH FROM ${users.joinDate})`); + .groupBy(sql`strftime('%m', ${users.joinDate})`) + .orderBy(sql`strftime('%m', ${users.joinDate})`); // Create array of all months with count 0 const allMonths = Array.from({ length: 12 }, (_, i) => ({ @@ -32,19 +32,19 @@ export async function getRegistrationsByMonth() { export async function getCheckinsByMonth() { const monthlyCheckins = await db .select({ - month: sql`EXTRACT(MONTH FROM ${checkins.time})`.mapWith(Number), - year: sql`EXTRACT(YEAR FROM ${checkins.time})`.mapWith(Number), + month: sql`strftime('%m', ${checkins.time})`.mapWith(Number), + year: sql`strftime('%Y', ${checkins.time})`.mapWith(Number), count: count(), }) .from(checkins) .where( - sql`EXTRACT(YEAR FROM ${checkins.time}) = EXTRACT(YEAR FROM NOW()) AND ${checkins.time} < NOW()`, + sql`strftime('%Y', ${checkins.time}) = strftime('%Y', datetime('now')) AND ${checkins.time} < datetime('now')`, ) .groupBy( - sql`EXTRACT(MONTH FROM ${checkins.time})`, - sql`EXTRACT(YEAR FROM ${checkins.time})`, + sql`strftime('%m', ${checkins.time})`, + sql`strftime('%Y', ${checkins.time})`, ) - .orderBy(sql`EXTRACT(MONTH FROM ${checkins.time})`); + .orderBy(sql`strftime('%m', ${checkins.time})`); // Create array of all months with count 0 const allMonths = Array.from({ length: 12 }, (_, i) => ({ @@ -79,19 +79,23 @@ export async function getUserClassifications() { // Get gender distribution of members export async function getGenderDistribution() { - // Query the unnested gender array to count each gender + // Query using JSON functions to handle arrays const result = await db .select({ - gender: sql`LOWER(unnest(${data.gender}))`.mapWith(String), - fill: sql`CONCAT('var(--color-',LOWER(REPLACE(unnest(${data.gender}), ' ', '-')),')')`.mapWith( + gender: sql`LOWER(json_each.value)`.mapWith(String), + fill: sql`CONCAT('var(--color-',LOWER(REPLACE(json_each.value, ' ', '-')),')')`.mapWith( String, ), count: count(), }) .from(data) + .innerJoin( + sql`json_each(${data.gender})`, + sql`1=1`, // Join condition (always true) + ) .groupBy( - sql`LOWER(unnest(${data.gender}))`, - sql`LOWER(REPLACE(unnest(${data.gender}), ' ', '-'))`, + sql`LOWER(json_each.value)`, + sql`LOWER(REPLACE(json_each.value, ' ', '-'))`, ) .orderBy(desc(count())); @@ -102,16 +106,20 @@ export async function getGenderDistribution() { export async function getRaceDistribution() { const result = await db .select({ - race: sql`LOWER(unnest(${data.ethnicity}))`.mapWith(String), + race: sql`LOWER(json_each.value)`.mapWith(String), count: count(), - fill: sql`CONCAT('var(--color-',LOWER(REPLACE(unnest(${data.ethnicity}), ' ', '-')),')')`.mapWith( + fill: sql`CONCAT('var(--color-',LOWER(REPLACE(json_each.value, ' ', '-')),')')`.mapWith( String, ), }) .from(data) + .innerJoin( + sql`json_each(${data.ethnicity})`, + sql`1=1`, // Join condition (always true) + ) .groupBy( - sql`LOWER(unnest(${data.ethnicity}))`, - sql`LOWER(REPLACE(unnest(${data.ethnicity}), ' ', '-'))`, + sql`LOWER(json_each.value)`, + sql`LOWER(REPLACE(json_each.value, ' ', '-'))`, ) .orderBy(desc(count())); @@ -128,7 +136,7 @@ export async function getActiveMembers() { activeMembers: count(sql`DISTINCT ${checkins.userID}`), }) .from(checkins) - .where(sql`${checkins.time} >= NOW() - INTERVAL '30 days'`); + .where(sql`${checkins.time} >= datetime('now', '-30 days')`); return result[0]?.activeMembers || 0; } @@ -142,7 +150,7 @@ export async function getRetentionRate() { }) .from(checkins) .where( - sql`${checkins.time} >= NOW() - INTERVAL '60 days' AND ${checkins.time} < NOW() - INTERVAL '30 days'`, + sql`${checkins.time} >= datetime('now', '-60 days') AND ${checkins.time} < datetime('now', '-30 days')`, ) .groupBy(checkins.userID); @@ -152,7 +160,7 @@ export async function getRetentionRate() { userID: checkins.userID, }) .from(checkins) - .where(sql`${checkins.time} >= NOW() - INTERVAL '30 days'`) + .where(sql`${checkins.time} >= datetime('now', '-30 days')`) .groupBy(checkins.userID); const lastMonthUserIds = lastMonthActiveUsers.map((u) => u.userID); @@ -178,7 +186,7 @@ export async function getGrowthRate() { }) .from(users) .where( - sql`${users.joinDate} >= DATE_TRUNC('month', NOW()) AND ${users.joinDate} < NOW()`, + sql`${users.joinDate} >= date(datetime('now', 'start of month')) AND ${users.joinDate} < datetime('now')`, ); // Previous month signups @@ -188,7 +196,7 @@ export async function getGrowthRate() { }) .from(users) .where( - sql`${users.joinDate} >= DATE_TRUNC('month', NOW() - INTERVAL '1 month') AND ${users.joinDate} < DATE_TRUNC('month', NOW())`, + sql`${users.joinDate} >= date(datetime('now', '-1 month', 'start of month')) AND ${users.joinDate} < date(datetime('now', 'start of month'))`, ); const currentMonthCount = currentMonthResult[0]?.count || 0; @@ -206,16 +214,16 @@ export async function getGrowthRate() { export async function getActivityByDayOfWeek() { const result = await db .select({ - day: sql`TO_CHAR(${checkins.time}, 'Dy')`.mapWith(String), + day: sql`strftime('%a', ${checkins.time})`.mapWith(String), count: count(), }) .from(checkins) - .where(sql`${checkins.time} >= NOW() - INTERVAL '90 days'`) + .where(sql`${checkins.time} >= datetime('now', '-90 days')`) .groupBy( - sql`TO_CHAR(${checkins.time}, 'Dy')`, - sql`TO_CHAR(${checkins.time}, 'ID')`, + sql`strftime('%a', ${checkins.time})`, + sql`strftime('%w', ${checkins.time})`, ) - .orderBy(sql`TO_CHAR(${checkins.time}, 'ID')`); // Order by day number (Mon=1, Sun=7) + .orderBy(sql`strftime('%w', ${checkins.time})`); // Order by day number (Sun=0, Sat=6) return result; } @@ -224,16 +232,16 @@ export async function getActivityByDayOfWeek() { export async function getMostActiveDay() { const result = await db .select({ - day: sql`TO_CHAR(${checkins.time}, 'Day')`.mapWith(String), + day: sql`strftime('%A', ${checkins.time})`.mapWith(String), count: count(), }) .from(checkins) - .where(sql`${checkins.time} >= NOW() - INTERVAL '90 days'`) - .groupBy(sql`TO_CHAR(${checkins.time}, 'Day')`) + .where(sql`${checkins.time} >= datetime('now', '-90 days')`) + .groupBy(sql`strftime('%A', ${checkins.time})`) .orderBy(desc(count())) .limit(1); - return result[0]?.day.trim() || "Wednesday"; + return result[0]?.day || "Wednesday"; } // Get activity by time of day @@ -257,7 +265,7 @@ export async function getActivityByTimeOfDay() { }) .from(checkins) .where( - sql`${checkins.time} >= NOW() - INTERVAL '90 days' AND EXTRACT(HOUR FROM ${checkins.time}) >= ${slot.start} AND EXTRACT(HOUR FROM ${checkins.time}) < ${slot.end}`, + sql`${checkins.time} >= datetime('now', '-90 days') AND cast(strftime('%H', ${checkins.time}) as integer) >= ${slot.start} AND cast(strftime('%H', ${checkins.time}) as integer) < ${slot.end}`, ); results.push({ @@ -283,9 +291,9 @@ export async function getMembershipStatus() { const result = await db .select({ status: sql`CASE - WHEN ${users.joinDate} >= NOW() - INTERVAL '7 days' THEN 'Trial' - WHEN ${users.joinDate} >= NOW() - INTERVAL '90 days' THEN 'Active' - WHEN ${users.joinDate} >= NOW() - INTERVAL '180 days' THEN 'Expired' + WHEN ${users.joinDate} >= datetime('now', '-7 days') THEN 'Trial' + WHEN ${users.joinDate} >= datetime('now', '-90 days') THEN 'Active' + WHEN ${users.joinDate} >= datetime('now', '-180 days') THEN 'Expired' ELSE 'Inactive' END`.mapWith(String), count: count(), @@ -293,9 +301,9 @@ export async function getMembershipStatus() { .from(users) .groupBy( sql`CASE - WHEN ${users.joinDate} >= NOW() - INTERVAL '7 days' THEN 'Trial' - WHEN ${users.joinDate} >= NOW() - INTERVAL '90 days' THEN 'Active' - WHEN ${users.joinDate} >= NOW() - INTERVAL '180 days' THEN 'Expired' + WHEN ${users.joinDate} >= datetime('now', '-7 days') THEN 'Trial' + WHEN ${users.joinDate} >= datetime('now', '-90 days') THEN 'Active' + WHEN ${users.joinDate} >= datetime('now', '-180 days') THEN 'Expired' ELSE 'Inactive' END`, ) From 62b60d46e2a22bb2c23499d2db6074979f0497d4 Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Thu, 20 Mar 2025 16:41:32 -0500 Subject: [PATCH 10/15] Skeleton improvements --- apps/web/src/app/admin/categories/loading.tsx | 15 +----- apps/web/src/app/admin/checkins/loading.tsx | 5 ++ apps/web/src/app/admin/checkins/page.tsx | 10 ++-- apps/web/src/app/admin/events/loading.tsx | 5 ++ apps/web/src/app/admin/events/page.tsx | 5 +- apps/web/src/app/admin/semesters/loading.tsx | 5 ++ apps/web/src/app/admin/semesters/page.tsx | 4 +- .../dash/admin/categories/CategoryView.tsx | 15 +++--- .../dash/admin/semesters/SemesterView.tsx | 2 + .../dash/shared/AdminPageSkeleton.tsx | 54 +++++++++++++++++++ .../src/components/ui/skeleton-loaders.tsx | 45 ++++++++-------- 11 files changed, 114 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/app/admin/checkins/loading.tsx create mode 100644 apps/web/src/app/admin/events/loading.tsx create mode 100644 apps/web/src/app/admin/semesters/loading.tsx create mode 100644 apps/web/src/components/dash/shared/AdminPageSkeleton.tsx diff --git a/apps/web/src/app/admin/categories/loading.tsx b/apps/web/src/app/admin/categories/loading.tsx index 71c7c40e..da5869df 100644 --- a/apps/web/src/app/admin/categories/loading.tsx +++ b/apps/web/src/app/admin/categories/loading.tsx @@ -1,16 +1,5 @@ -import { TableSkeleton } from "@/components/ui/skeleton-loaders"; +import { AdminPageSkeleton } from "@/components/dash/shared/AdminPageSkeleton"; export default function Loading() { - return ( -
-
-

- Categories -

-
-
- -
-
- ); + return ; } diff --git a/apps/web/src/app/admin/checkins/loading.tsx b/apps/web/src/app/admin/checkins/loading.tsx new file mode 100644 index 00000000..6a77df89 --- /dev/null +++ b/apps/web/src/app/admin/checkins/loading.tsx @@ -0,0 +1,5 @@ +import { AdminPageSkeleton } from "@/components/dash/shared/AdminPageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/apps/web/src/app/admin/checkins/page.tsx b/apps/web/src/app/admin/checkins/page.tsx index cd2ac801..b5299fec 100644 --- a/apps/web/src/app/admin/checkins/page.tsx +++ b/apps/web/src/app/admin/checkins/page.tsx @@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button"; import AdminCheckinLog from "@/components/dash/shared/AdminCheckinLog"; import { getEventList } from "@/lib/queries/events"; import { Dialog, DialogTrigger } from "@/components/ui/dialog"; +import { AdminPageSkeletonContent } from "@/components/dash/shared/AdminPageSkeleton"; +import { TableSkeleton } from "@/components/ui/skeleton-loaders"; export default async function Page() { const eventList = await getEventList(); @@ -16,9 +18,9 @@ export default async function Page() { Checkins
-
+
Grabbing checkin stats. One sec...
} + fallback={
Loading stats...
} > @@ -37,9 +39,7 @@ export default async function Page() { {/*
{events?.[0].name}
*/}
- Grabbing checkin log. One sec...
} - > + }>
diff --git a/apps/web/src/app/admin/events/loading.tsx b/apps/web/src/app/admin/events/loading.tsx new file mode 100644 index 00000000..b326910b --- /dev/null +++ b/apps/web/src/app/admin/events/loading.tsx @@ -0,0 +1,5 @@ +import { AdminPageSkeleton } from "@/components/dash/shared/AdminPageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/apps/web/src/app/admin/events/page.tsx b/apps/web/src/app/admin/events/page.tsx index 4d7f33c7..ef347718 100644 --- a/apps/web/src/app/admin/events/page.tsx +++ b/apps/web/src/app/admin/events/page.tsx @@ -9,6 +9,7 @@ import EventStatsSheet from "@/components/dash/admin/events/EventStatsSheet"; import { Button } from "@/components/ui/button"; import AdminCheckinLog from "@/components/dash/shared/AdminCheckinLog"; import { unstable_noStore as noStore } from "next/cache"; +import { TableSkeleton } from "@/components/ui/skeleton-loaders"; async function Page() { noStore(); @@ -20,9 +21,9 @@ async function Page() { Events
-
+
Grabbing event stats. One sec...
} + fallback={
Loading stats...
} > diff --git a/apps/web/src/app/admin/semesters/loading.tsx b/apps/web/src/app/admin/semesters/loading.tsx new file mode 100644 index 00000000..af97844d --- /dev/null +++ b/apps/web/src/app/admin/semesters/loading.tsx @@ -0,0 +1,5 @@ +import { AdminPageSkeleton } from "@/components/dash/shared/AdminPageSkeleton"; + +export default function Loading() { + return ; +} diff --git a/apps/web/src/app/admin/semesters/page.tsx b/apps/web/src/app/admin/semesters/page.tsx index 6254c704..a6f2262a 100644 --- a/apps/web/src/app/admin/semesters/page.tsx +++ b/apps/web/src/app/admin/semesters/page.tsx @@ -1,5 +1,7 @@ import { Suspense } from "react"; import AdminSemesterView from "@/components/dash/admin/semesters/SemesterView"; +import { AdminPageSkeletonContent } from "@/components/dash/shared/AdminPageSkeleton"; + export default function Page() { return (
@@ -8,7 +10,7 @@ export default function Page() { Semesters
- Grabbing checkin stats. One sec...
}> + }> diff --git a/apps/web/src/components/dash/admin/categories/CategoryView.tsx b/apps/web/src/components/dash/admin/categories/CategoryView.tsx index 8db110b5..1ff90b66 100644 --- a/apps/web/src/components/dash/admin/categories/CategoryView.tsx +++ b/apps/web/src/components/dash/admin/categories/CategoryView.tsx @@ -2,22 +2,19 @@ import { getAllCategories } from "@/lib/queries/categories"; import { DataTable } from "@/components/ui/data-table"; import CreateCategory from "./CreateCategoryDialogue"; import { eventCategoryColumns } from "@/app/admin/categories/columns"; +import StatItem from "../../shared/StatItem"; export default async function AdminCategoryView() { const categories = await getAllCategories(); return ( <> -
+
-
- - Total Categories - - - {categories.length} - -
+
diff --git a/apps/web/src/components/dash/admin/semesters/SemesterView.tsx b/apps/web/src/components/dash/admin/semesters/SemesterView.tsx index d0613d33..91a7e1da 100644 --- a/apps/web/src/components/dash/admin/semesters/SemesterView.tsx +++ b/apps/web/src/components/dash/admin/semesters/SemesterView.tsx @@ -3,6 +3,8 @@ import { getAllSemesters } from "@/lib/queries/semesters"; import { semesterColumns } from "@/app/admin/semesters/columns"; import CreateSemesterDialogue from "./CreateSemesterDialogue"; +export const dynamic = "force-dynamic"; + export default async function AdminSemesterView() { const semesters = await getAllSemesters(); diff --git a/apps/web/src/components/dash/shared/AdminPageSkeleton.tsx b/apps/web/src/components/dash/shared/AdminPageSkeleton.tsx new file mode 100644 index 00000000..f77a2270 --- /dev/null +++ b/apps/web/src/components/dash/shared/AdminPageSkeleton.tsx @@ -0,0 +1,54 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { TableSkeleton } from "@/components/ui/skeleton-loaders"; + +interface AdminPageSkeletonProps { + title: string; + rows?: number; +} + +export function AdminPageSkeleton({ title, rows = 8 }: AdminPageSkeletonProps) { + return ( +
+
+

+ {title} +

+
+
+
+
+ + +
+
+
+ +
+
+
+ +
+
+ ); +} + +export function AdminPageSkeletonContent({ rows = 8 }: { rows?: number }) { + return ( + <> +
+
+
+ + +
+
+
+ +
+
+
+ +
+ + ); +} diff --git a/apps/web/src/components/ui/skeleton-loaders.tsx b/apps/web/src/components/ui/skeleton-loaders.tsx index 54a9210d..2e750f78 100644 --- a/apps/web/src/components/ui/skeleton-loaders.tsx +++ b/apps/web/src/components/ui/skeleton-loaders.tsx @@ -1,4 +1,12 @@ import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from "@/components/ui/table"; // Basic content skeleton for general use export function ContentSkeleton() { @@ -29,31 +37,26 @@ export function CardSkeleton() { // Table skeleton for data tables export function TableSkeleton({ rows = 5 }: { rows?: number }) { return ( -
-
- {Array(4) - .fill(0) - .map((_, i) => ( - - ))} -
-
+ + + + + + + + + {Array(rows) .fill(0) .map((_, i) => ( -
- {Array(4) - .fill(0) - .map((_, j) => ( - - ))} -
+ + + + + ))} - - +
+
); } From f16f52e7056742cd528f4780ffe0008918f66b11 Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Tue, 1 Apr 2025 23:24:34 -0500 Subject: [PATCH 11/15] remove unneccessary prop eventID on CheckinsStatsSheet --- apps/web/next-env.d.ts | 2 +- apps/web/src/app/admin/events/[id]/checkins/page.tsx | 2 +- apps/web/src/middleware.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 40c3d680..4f11a03d 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/web/src/app/admin/events/[id]/checkins/page.tsx b/apps/web/src/app/admin/events/[id]/checkins/page.tsx index 76a39b31..b219426f 100644 --- a/apps/web/src/app/admin/events/[id]/checkins/page.tsx +++ b/apps/web/src/app/admin/events/[id]/checkins/page.tsx @@ -36,7 +36,7 @@ export default async function EventCheckinsPage({ Grabbing checkin stats. One sec...
} > - +
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index ce220505..501b15ac 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -14,7 +14,7 @@ export default clerkMiddleware(async (auth, req) => { const { userId } = await auth(); if (isProtectedRoute(req)) { - await auth.protect(); + await auth().protect(); } // protect admin api routes if (isAdminAPIRoute(req)) { From 4899813aacc35ea9779d8e905a679821d1750052 Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Tue, 1 Apr 2025 23:27:58 -0500 Subject: [PATCH 12/15] add await to auth call --- apps/web/src/middleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 501b15ac..ef924f85 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -14,7 +14,7 @@ export default clerkMiddleware(async (auth, req) => { const { userId } = await auth(); if (isProtectedRoute(req)) { - await auth().protect(); + await (await auth()).protect(); } // protect admin api routes if (isAdminAPIRoute(req)) { From 100c2adce9799402a7b30c88bb21dc840d9c2c19 Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Tue, 1 Apr 2025 23:32:09 -0500 Subject: [PATCH 13/15] fix middleware auth call again --- apps/web/src/middleware.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index ef924f85..8f63502f 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -11,10 +11,10 @@ const isAdminAPIRoute = createRouteMatcher(["/api/admin(.*)"]); // come back and check if this is valid export default clerkMiddleware(async (auth, req) => { - const { userId } = await auth(); + const { userId, protect } = await auth(); if (isProtectedRoute(req)) { - await (await auth()).protect(); + await protect(); } // protect admin api routes if (isAdminAPIRoute(req)) { From d45305a2cc9797ebebd2a19dd6199e75c9a5d9ea Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Tue, 1 Apr 2025 23:40:45 -0500 Subject: [PATCH 14/15] fix auth again? --- apps/web/src/middleware.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 8f63502f..cc6ace05 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,7 +1,6 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { getAdminUser } from "./lib/queries/users"; import { NextResponse } from "next/server"; - const isProtectedRoute = createRouteMatcher([ "/dash(.*)", "/admin(.*)", @@ -11,11 +10,12 @@ const isAdminAPIRoute = createRouteMatcher(["/api/admin(.*)"]); // come back and check if this is valid export default clerkMiddleware(async (auth, req) => { - const { userId, protect } = await auth(); + const { userId } = await auth(); if (isProtectedRoute(req)) { - await protect(); + await auth.protect(); } + // protect admin api routes if (isAdminAPIRoute(req)) { if (!userId || !(await getAdminUser(userId))) { From 7e044298503973b35a53d3746aec30f573928ffb Mon Sep 17 00:00:00 2001 From: joshuasilva414 Date: Tue, 1 Apr 2025 23:41:09 -0500 Subject: [PATCH 15/15] run prettier --- apps/web/src/app/globals.css | 10 ++--- apps/web/src/components/dash/UserDash.tsx | 2 +- .../src/components/events/id/EventDetails.tsx | 2 +- apps/web/src/components/ui/progress.tsx | 44 +++++++++---------- 4 files changed, 28 insertions(+), 30 deletions(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 9ecb8f20..e713b799 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -132,13 +132,11 @@ } } - - @layer base { - * { - @apply border-border outline-ring/50; + * { + @apply border-border outline-ring/50; } - body { - @apply bg-background text-foreground; + body { + @apply bg-background text-foreground; } } diff --git a/apps/web/src/components/dash/UserDash.tsx b/apps/web/src/components/dash/UserDash.tsx index af5541b5..0148cd7c 100644 --- a/apps/web/src/components/dash/UserDash.tsx +++ b/apps/web/src/components/dash/UserDash.tsx @@ -140,7 +140,7 @@ export default async function UserDash({ {`${userData.major}, ${userData.graduationYear}`}

-

+

{`Member since ${joinedDate}`}

{/* come back and configure wrangler json */} diff --git a/apps/web/src/components/events/id/EventDetails.tsx b/apps/web/src/components/events/id/EventDetails.tsx index 09df76ef..e3a89915 100644 --- a/apps/web/src/components/events/id/EventDetails.tsx +++ b/apps/web/src/components/events/id/EventDetails.tsx @@ -154,7 +154,7 @@ export default async function EventDetails({ Description

{description}

diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx index 5c87ea48..6a8844de 100644 --- a/apps/web/src/components/ui/progress.tsx +++ b/apps/web/src/components/ui/progress.tsx @@ -1,28 +1,28 @@ -"use client" +"use client"; -import * as React from "react" -import * as ProgressPrimitive from "@radix-ui/react-progress" +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Progress = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, value, ...props }, ref) => ( - - - -)) -Progress.displayName = ProgressPrimitive.Root.displayName + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; -export { Progress } +export { Progress };