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/package.json b/apps/web/package.json index 7790ced1..fbdb6bbb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,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/categories/loading.tsx b/apps/web/src/app/admin/categories/loading.tsx new file mode 100644 index 00000000..da5869df --- /dev/null +++ b/apps/web/src/app/admin/categories/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/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/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/[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/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 e7bb80c7..5998d6bb 100644 --- a/apps/web/src/app/admin/events/page.tsx +++ b/apps/web/src/app/admin/events/page.tsx @@ -8,6 +8,7 @@ import { DataTable } from "@/components/ui/data-table"; import EventStatsSheet from "@/components/dash/admin/events/EventStatsSheet"; import { Button } from "@/components/ui/button"; import { unstable_noStore as noStore } from "next/cache"; +import { TableSkeleton } from "@/components/ui/skeleton-loaders"; async function Page() { noStore(); @@ -19,9 +20,9 @@ async function Page() { Events
-
+
Grabbing event stats. One sec...
} + fallback={
Loading stats...
} > diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx index 225c4e59..7f27b77d 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/columns.tsx b/apps/web/src/app/admin/members/columns.tsx index 43d4e5b7..7f226005 100644 --- a/apps/web/src/app/admin/members/columns.tsx +++ b/apps/web/src/app/admin/members/columns.tsx @@ -23,7 +23,10 @@ import Link from "next/link"; 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}
; }; @@ -126,6 +129,14 @@ export const columns: ColumnDef[] = [ return ; }, }, + { + accessorKey: "user.joinDate", + id: "joinDate", + header: ({ column }) => { + return ; + }, + cell: ({ row }) => timeCell({ row }), + }, { id: "actions", enablePinning: true, 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 e5e278e8..9ab64e61 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
}> - - +
-
-

- Trends +
+
+

+ Admin Overview

+

+ Monitor key club metrics and trends +

+
-
-
- - Retrieving registration chart. One sec... -
- } - > - - -
-
-
-
-

- Demographics -

-
-
- - Retrieving demographics charts. One sec... -
- } - > - - -
-

+ + {/* Key Metrics Summary Cards with streaming */} + }> + + + + {/* Trends Section with streaming */} + } + > + + + + {/* Engagement Metrics with streaming */} + } + > + + + + {/* Activity Patterns with streaming */} + } + > + + + + {/* Demographics with streaming */} + } + > + +
); } 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/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 f8b34ced..c52caccc 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 async 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/app/globals.css b/apps/web/src/app/globals.css index 074c5462..e713b799 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,12 @@ 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/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/dash/admin/categories/CategoryView.tsx b/apps/web/src/components/dash/admin/categories/CategoryView.tsx index 819446e5..f647905b 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/events/EventStatsSheet.tsx b/apps/web/src/components/dash/admin/events/EventStatsSheet.tsx index 3130faff..39f4a390 100644 --- a/apps/web/src/components/dash/admin/events/EventStatsSheet.tsx +++ b/apps/web/src/components/dash/admin/events/EventStatsSheet.tsx @@ -1,38 +1,29 @@ import React from "react"; import { getEventStatsOverview } from "@/lib/queries/events"; - -import { Separator } from "@/components/ui/separator"; +import { StatItemProps } from "@/components/dash/shared/StatItem"; +import StatsSheet from "@/components/dash/shared/StatsSheet"; type Props = {}; async function EventStatsSheet({}: Props) { const stats = await getEventStatsOverview(); - return ( -
-
- - Total Events - - - {stats.totalEvents} - -
- -
- This Week - {stats.thisWeek} -
- -
- - Past Events - - - {stats.pastEvents} - -
-
- ); + + const statItems: StatItemProps[] = [ + { + label: "Total Events", + value: stats.totalEvents, + }, + { + label: "This Week", + value: stats.thisWeek, + }, + { + label: "Past Events", + value: stats.pastEvents, + }, + ]; + + return ; } export default EventStatsSheet; diff --git a/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx b/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx index c1611c77..3f0d2207 100644 --- a/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx +++ b/apps/web/src/components/dash/admin/members/MemberStatsSheet.tsx @@ -1,39 +1,25 @@ import React from "react"; import { getMemberStatsOverview } from "@/lib/queries/users"; - -import { Separator } from "@/components/ui/separator"; +import { StatItemProps } from "@/components/dash/shared/StatItem"; +import StatsSheet from "@/components/dash/shared/StatsSheet"; type Props = {}; async function MemberStatsSheet({}: Props) { const stats = await getMemberStatsOverview(); - return ( -
-
- - Total Members - - - {stats.totalMembers} - -
- - {/* Put recent registration count here */} - {/*
- This Week - {stats.thisWeek} -
- */} -
- - Active Members - - - {stats.activeMembers} - -
-
- ); + + const statItems: StatItemProps[] = [ + { + label: "Total Members", + value: stats.totalMembers, + }, + { + label: "Active Members", + value: stats.activeMembers, + }, + ]; + + return ; } export default MemberStatsSheet; diff --git a/apps/web/src/components/dash/admin/overview/ActivityByDayChart.tsx b/apps/web/src/components/dash/admin/overview/ActivityByDayChart.tsx new file mode 100644 index 00000000..e477c3e8 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/ActivityByDayChart.tsx @@ -0,0 +1,48 @@ +"use client"; + +import React from "react"; +import { CalendarDaysIcon } from "lucide-react"; + +type DayActivity = { + day: string; + count: number; +}; + +type Props = { + activityByDayOfWeek: DayActivity[]; + mostActiveDay: string; +}; + +export default function ActivityByDayChart({ + activityByDayOfWeek, + mostActiveDay, +}: Props) { + // Find the highest count to calculate relative heights + const maxCount = Math.max(...activityByDayOfWeek.map((day) => day.count)); + + return ( +
+ +
{mostActiveDay}
+
+ {activityByDayOfWeek.map((day) => ( +
+
+
+ {day.day} +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/dash/admin/overview/ActivityPatterns.tsx b/apps/web/src/components/dash/admin/overview/ActivityPatterns.tsx new file mode 100644 index 00000000..8c0bfc47 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/ActivityPatterns.tsx @@ -0,0 +1,110 @@ +import { Suspense } from "react"; +import { + getActivityByTimeOfDay, + getMembershipStatus, +} from "@/lib/queries/charts"; +import TimeOfDayChart from "./TimeOfDayChart"; +import MembershipStatusChart from "./MembershipStatusChart"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; + +export default function ActivityPatterns() { + return ( +
+
+

+ Activity Patterns +

+
+
+ + } + > + + + + } + > + + +
+
+ ); +} + +function LoadingCard({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( + + + {title} + + {description} + + + +
+ Loading... +
+
+
+ ); +} + +async function TimeOfDayCard() { + const activityByTimeOfDay = await getActivityByTimeOfDay(); + + return ( + + + + Activity by Time of Day + + + When members most frequently visit + + + + + + + ); +} + +async function MembershipStatusCard() { + const membershipStatus = await getMembershipStatus(); + + return ( + + + Membership Status + + Distribution of member statuses + + + + + + + ); +} diff --git a/apps/web/src/components/dash/admin/overview/AverageVisitsCard.tsx b/apps/web/src/components/dash/admin/overview/AverageVisitsCard.tsx new file mode 100644 index 00000000..26d9beae --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/AverageVisitsCard.tsx @@ -0,0 +1,20 @@ +"use client"; + +import React from "react"; + +type Props = { + averageVisits: string; +}; + +export default function AverageVisitsCard({ averageVisits }: Props) { + return ( +
+
+
{averageVisits}
+
+ visits/member +
+
+
+ ); +} diff --git a/apps/web/src/components/dash/admin/overview/DemographicsSection.tsx b/apps/web/src/components/dash/admin/overview/DemographicsSection.tsx new file mode 100644 index 00000000..73b24828 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/DemographicsSection.tsx @@ -0,0 +1,139 @@ +import { Suspense } from "react"; +import { + getUserClassifications, + getGenderDistribution, + getRaceDistribution, +} from "@/lib/queries/charts"; +import DemographicsStats from "./DemographicsStats"; +import GenderDistributionChart from "./GenderDistributionChart"; +import RaceDistributionChart from "./RaceDistributionChart"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; + +export default function DemographicsSection() { + return ( +
+
+

+ Member Demographics +

+
+ +
+ + } + > + + + + + } + > + + + + + } + > + + +
+
+ ); +} + +function LoadingCard({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( + + + {title} + + {description} + + + +
+ Loading... +
+
+
+ ); +} + +async function ClassificationCard() { + const classifications = await getUserClassifications(); + + return ( + + + Member Classification + Distribution by member level + + + + + + ); +} + +async function GenderCard() { + const genderData = await getGenderDistribution(); + + return ( + + + Gender Distribution + + Breakdown of members by gender + + + + + + + ); +} + +async function RaceCard() { + const raceData = await getRaceDistribution(); + + return ( + + + Race/Ethnicity + + Breakdown of members by race/ethnicity + + + + + + + ); +} diff --git a/apps/web/src/components/dash/admin/overview/DemographicsStats.tsx b/apps/web/src/components/dash/admin/overview/DemographicsStats.tsx index e368d93c..3cebb45f 100644 --- a/apps/web/src/components/dash/admin/overview/DemographicsStats.tsx +++ b/apps/web/src/components/dash/admin/overview/DemographicsStats.tsx @@ -61,37 +61,34 @@ function DemographicsStats({ classifications }: Props) { return (
- - - Classifications - - - - - } - /> - - - } - className="-translate-y-2 flex-wrap gap-2 [&>*]:basis-1/4 [&>*]:justify-center" - /> - - - - + + + } + /> + + } /> + + } + layout="horizontal" + verticalAlign="bottom" + align="center" + /> + +
); diff --git a/apps/web/src/components/dash/admin/overview/EngagementMetricsCard.tsx b/apps/web/src/components/dash/admin/overview/EngagementMetricsCard.tsx new file mode 100644 index 00000000..270fd775 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/EngagementMetricsCard.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React from "react"; +import { Progress } from "@/components/ui/progress"; + +type Props = { + activeMembers: number; + totalRegistrations: number; +}; + +export default function EngagementMetricsCard({ + activeMembers, + totalRegistrations, +}: Props) { + const activePercentage = Math.round( + (activeMembers / totalRegistrations) * 100, + ); + + return ( +
+
+
{activeMembers}
+
+ of {totalRegistrations} ({activePercentage}%) +
+
+ +
+ ); +} diff --git a/apps/web/src/components/dash/admin/overview/EngagementSection.tsx b/apps/web/src/components/dash/admin/overview/EngagementSection.tsx new file mode 100644 index 00000000..40e99cf4 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/EngagementSection.tsx @@ -0,0 +1,171 @@ +import { Suspense } from "react"; +import { + getActiveMembers, + getRegistrationsByMonth, + getCheckinsByMonth, + getMostActiveDay, + getActivityByDayOfWeek, +} from "@/lib/queries/charts"; +import EngagementMetricsCard from "./EngagementMetricsCard"; +import AverageVisitsCard from "./AverageVisitsCard"; +import ActivityByDayChart from "./ActivityByDayChart"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; + +export default function EngagementSection() { + return ( +
+
+

+ Engagement Metrics +

+
+
+ + } + > + + + + } + > + + + + } + > + + +
+
+ ); +} + +function LoadingCard({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( + + + {title} + + {description} + + + +
+ Loading... +
+
+
+ ); +} + +async function ActiveMembersCard() { + const [activeMembers, registrations] = await Promise.all([ + getActiveMembers(), + getRegistrationsByMonth(), + ]); + + const totalRegistrations = registrations.reduce( + (acc, curr) => acc + curr.count, + 0, + ); + + return ( + + + Active Members + + Members who visited in the last 30 days + + + + + + + ); +} + +async function VisitsCard() { + const [registrations, checkins] = await Promise.all([ + getRegistrationsByMonth(), + getCheckinsByMonth(), + ]); + + const totalRegistrations = registrations.reduce( + (acc, curr) => acc + curr.count, + 0, + ); + const totalCheckins = checkins.reduce((acc, curr) => acc + curr.count, 0); + const averageVisitsPerMember = ( + totalCheckins / (totalRegistrations || 1) + ).toFixed(1); + + return ( + + + + Avg. Visits per Member + + + Average check-ins per registered member + + + + + + + ); +} + +async function ActiveDayCard() { + const [mostActiveDay, activityByDayOfWeek] = await Promise.all([ + getMostActiveDay(), + getActivityByDayOfWeek(), + ]); + + return ( + + + Most Active Day + + Day with highest check-in activity + + + + + + + ); +} diff --git a/apps/web/src/components/dash/admin/overview/GenderDistributionChart.tsx b/apps/web/src/components/dash/admin/overview/GenderDistributionChart.tsx new file mode 100644 index 00000000..015eb7a1 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/GenderDistributionChart.tsx @@ -0,0 +1,98 @@ +"use client"; + +import React from "react"; +import { Label, Pie, PieChart, Cell } from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartConfig, +} from "@/components/ui/chart"; + +type GenderData = { + gender: string; + count: number; + fill: string; +}; + +type Props = { + genderData: GenderData[]; +}; + +const chartConfig = { + male: { + label: "Male", + color: "hsl(var(--chart-1))", + }, + female: { + label: "Female", + color: "hsl(var(--chart-2))", + }, + "non-binary": { + label: "Non-binary", + color: "hsl(var(--chart-3))", + }, + transgender: { + label: "Transgender", + color: "hsl(var(--chart-4))", + }, + "prefer-not-to-say": { + label: "Prefer not to say", + color: "hsl(var(--chart-5))", + }, + other: { + label: "Other", + color: "hsl(var(--chart-6))", + }, +} satisfies ChartConfig; + +export default function GenderDistributionChart({ genderData }: Props) { + // Format gender labels with capitalized first letter and count + // Also convert spaces to hyphens to match chartConfig keys + const formattedData = genderData.map((item) => ({ + ...item, + // Add a configKey property that uses hyphens instead of spaces + configKey: item.gender.replace(/\s+/g, "-"), + formattedLabel: `${item.gender.charAt(0).toUpperCase() + item.gender.slice(1)}`, + })); + + const total = formattedData.reduce((sum, item) => sum + item.count, 0); + return ( +
+
+ + + + } + /> + + + + } + layout="horizontal" + verticalAlign="bottom" + align="center" + /> + + +
+
+ ); +} diff --git a/apps/web/src/components/dash/admin/overview/KeyMetrics.tsx b/apps/web/src/components/dash/admin/overview/KeyMetrics.tsx new file mode 100644 index 00000000..9b8aec64 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/KeyMetrics.tsx @@ -0,0 +1,129 @@ +import { + getRegistrationsByMonth, + getCheckinsByMonth, + getRetentionRate, + getGrowthRate, +} from "@/lib/queries/charts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + UserIcon, + CheckCircleIcon, + BarChart, + ArrowUpIcon, + UsersIcon, + TrendingUpIcon, +} from "lucide-react"; +import { Progress } from "@/components/ui/progress"; + +export default async function KeyMetrics() { + // Fetch data in parallel + const [monthlyRegistrations, monthlyCheckins, retentionRate, growthRate] = + await Promise.all([ + getRegistrationsByMonth(), + getCheckinsByMonth(), + getRetentionRate(), + getGrowthRate(), + ]); + + // Calculate metrics + const totalRegistrations = monthlyRegistrations.reduce( + (acc, curr) => acc + curr.count, + 0, + ); + + const totalCheckins = monthlyCheckins.reduce( + (acc, curr) => acc + curr.count, + 0, + ); + + // Get new members this month + const newMembersThisMonth = + monthlyRegistrations[new Date().getMonth()]?.count || 0; + + return ( +
+ + + + Total Registrations + + + + +
+ {totalRegistrations} +
+

This year

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

This year

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

This month

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

+ Last 3 months +

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

+ vs last month +

+
+
+
+ ); +} diff --git a/apps/web/src/components/dash/admin/overview/MembershipStatusChart.tsx b/apps/web/src/components/dash/admin/overview/MembershipStatusChart.tsx new file mode 100644 index 00000000..bafaf197 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/MembershipStatusChart.tsx @@ -0,0 +1,44 @@ +"use client"; + +import React from "react"; +import { Progress } from "@/components/ui/progress"; + +type StatusData = { + status: string; + count: number; + color: string; +}; + +type Props = { + membershipStatus: StatusData[]; +}; + +export default function MembershipStatusChart({ membershipStatus }: Props) { + // Calculate total for percentages + const totalMembers = membershipStatus.reduce( + (acc, curr) => acc + curr.count, + 0, + ); + + return ( +
+ {membershipStatus.map((status) => ( +
+
+
+ {status.status} +
+
+ {status.count} ( + {Math.round((status.count / totalMembers) * 100)}%) +
+
+ +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx b/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx new file mode 100644 index 00000000..e0ec3701 --- /dev/null +++ b/apps/web/src/components/dash/admin/overview/MembershipTrends.tsx @@ -0,0 +1,51 @@ +import { Suspense } from "react"; +import { + getRegistrationsByMonth, + getCheckinsByMonth, +} from "@/lib/queries/charts"; +import MonthlyRegistrationChart from "./MonthlyRegistrationChart"; +import MonthlyCheckinChart from "./MonthlyCheckinChart"; +import { ChartSkeleton } from "@/components/ui/skeleton-loaders"; + +export default async function MembershipTrends() { + return ( +
+
+

+ Membership Trends +

+
+
+ + +
+ } + > + + + + +
+ } + > + + +
+ + ); +} + +// Split into separate components so each can fetch its own data independently +async function RegistrationChartWithData() { + const registrations = await getRegistrationsByMonth(); + return ; +} + +async function CheckinChartWithData() { + const checkins = await getCheckinsByMonth(); + return ; +} 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/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/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/dash/admin/semesters/SemesterView.tsx b/apps/web/src/components/dash/admin/semesters/SemesterView.tsx index 0d1de4d3..825bb780 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/dash/shared/CheckinStatsSheet.tsx b/apps/web/src/components/dash/shared/CheckinStatsSheet.tsx index 3579d8ac..6d642e61 100644 --- a/apps/web/src/components/dash/shared/CheckinStatsSheet.tsx +++ b/apps/web/src/components/dash/shared/CheckinStatsSheet.tsx @@ -1,21 +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"; -async function CheckinStatsSheet({ eventID }: { eventID?: string }) { - const stats = await getCheckinStatsOverview(eventID); - return ( -
-
- - Total Checkins - - - {stats.total_checkins} - -
-
- ); +type Props = {}; + +async function CheckinStatsSheet({}: Props) { + const stats = await getCheckinStatsOverview(); + + 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; 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 new file mode 100644 index 00000000..6a8844de --- /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/components/ui/skeleton-loaders.tsx b/apps/web/src/components/ui/skeleton-loaders.tsx new file mode 100644 index 00000000..2e750f78 --- /dev/null +++ b/apps/web/src/components/ui/skeleton-loaders.tsx @@ -0,0 +1,129 @@ +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() { + 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(rows) + .fill(0) + .map((_, i) => ( + + + + + + ))} + +
+ ); +} + +// 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 ( +
+
+ +
+ +
+ ); +} diff --git a/apps/web/src/lib/queries/charts.ts b/apps/web/src/lib/queries/charts.ts index cf185fe6..a1f59c07 100644 --- a/apps/web/src/lib/queries/charts.ts +++ b/apps/web/src/lib/queries/charts.ts @@ -1,18 +1,63 @@ -import { db, count, sql } from "db"; -import { data, users } from "db/schema"; +import { db, count, sql, eq, and, gt, lte, gte, desc, asc } from "db"; +import { data, users, checkins } from "db/schema"; export async function getRegistrationsByMonth() { - return await db + const monthlyRegistrations = await db .select({ month: sql`strftime('%m', ${users.joinDate})`.mapWith(Number), count: count(), }) .from(users) .where( - sql`${users.joinDate} > datetime('now', '-1 year') AND ${users.joinDate} < datetime('now')`, + sql`strftime('%Y', ${users.joinDate}) = strftime('%Y', datetime('now')) AND ${users.joinDate} < datetime('now')`, ) .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) => ({ + month: i + 1, + count: 0, + })); + + // Merge in actual registration counts + monthlyRegistrations.forEach((reg) => { + allMonths[reg.month - 1].count = reg.count; + }); + + return allMonths; +} + +// Checkins by month +export async function getCheckinsByMonth() { + const monthlyCheckins = await db + .select({ + month: sql`strftime('%m', ${checkins.time})`.mapWith(Number), + year: sql`strftime('%Y', ${checkins.time})`.mapWith(Number), + count: count(), + }) + .from(checkins) + .where( + sql`strftime('%Y', ${checkins.time}) = strftime('%Y', datetime('now')) AND ${checkins.time} < datetime('now')`, + ) + .groupBy( + sql`strftime('%m', ${checkins.time})`, + sql`strftime('%Y', ${checkins.time})`, + ) + .orderBy(sql`strftime('%m', ${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() { @@ -20,23 +65,252 @@ 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 using JSON functions to handle arrays + const result = await db + .select({ + 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(json_each.value)`, + sql`LOWER(REPLACE(json_each.value, ' ', '-'))`, + ) + .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(json_each.value)`.mapWith(String), count: count(), - fill: sql`CONCAT('var(--color-',LOWER(${data.gender}),')')`.mapWith( + fill: sql`CONCAT('var(--color-',LOWER(REPLACE(json_each.value, ' ', '-')),')')`.mapWith( String, ), }) .from(data) - .groupBy(sql`LOWER(${data.gender})`.mapWith(String)); + .innerJoin( + sql`json_each(${data.ethnicity})`, + sql`1=1`, // Join condition (always true) + ) + .groupBy( + sql`LOWER(json_each.value)`, + sql`LOWER(REPLACE(json_each.value, ' ', '-'))`, + ) + .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} >= datetime('now', '-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} >= datetime('now', '-60 days') AND ${checkins.time} < datetime('now', '-30 days')`, + ) + .groupBy(checkins.userID); + + // Members active this month + const thisMonthActiveUsers = await db + .select({ + userID: checkins.userID, + }) + .from(checkins) + .where(sql`${checkins.time} >= datetime('now', '-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(datetime('now', 'start of month')) AND ${users.joinDate} < datetime('now')`, + ); + + // Previous month signups + const previousMonthResult = await db + .select({ + count: count(), + }) + .from(users) + .where( + 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; + 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`strftime('%a', ${checkins.time})`.mapWith(String), + count: count(), + }) + .from(checkins) + .where(sql`${checkins.time} >= datetime('now', '-90 days')`) + .groupBy( + sql`strftime('%a', ${checkins.time})`, + sql`strftime('%w', ${checkins.time})`, + ) + .orderBy(sql`strftime('%w', ${checkins.time})`); // Order by day number (Sun=0, Sat=6) + + return result; +} + +// Get most active day +export async function getMostActiveDay() { + const result = await db + .select({ + day: sql`strftime('%A', ${checkins.time})`.mapWith(String), + count: count(), + }) + .from(checkins) + .where(sql`${checkins.time} >= datetime('now', '-90 days')`) + .groupBy(sql`strftime('%A', ${checkins.time})`) + .orderBy(desc(count())) + .limit(1); + + return result[0]?.day || "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} >= 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({ + 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} >= 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(), + }) + .from(users) + .groupBy( + sql`CASE + 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`, + ) + .orderBy(desc(count())); + + return result.map((item) => ({ + ...item, + color: statusColors[item.status] || "bg-gray-500", + })); } diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index ce220505..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(.*)", @@ -16,6 +15,7 @@ export default clerkMiddleware(async (auth, req) => { if (isProtectedRoute(req)) { await auth.protect(); } + // protect admin api routes if (isAdminAPIRoute(req)) { if (!userId || !(await getAdminUser(userId))) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b935939f..7e1cdfff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,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) @@ -2852,6 +2855,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: @@ -2879,6 +2895,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: @@ -3313,6 +3342,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: @@ -3489,6 +3559,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: