From f5a2196eef97ca05ba7a2a1211dbd7f968a652e4 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Wed, 28 Feb 2024 17:40:08 -0500 Subject: [PATCH 01/41] feat(admin panel): add left nav bar --- apps/recnet/src/app/Headerbar.tsx | 5 ++ apps/recnet/src/app/admin/AdminPanelNav.tsx | 75 +++++++++++++++++++++ apps/recnet/src/app/admin/layout.tsx | 25 +++++++ apps/recnet/src/app/admin/page.tsx | 7 ++ apps/recnet/src/types/user.ts | 1 + 5 files changed, 113 insertions(+) create mode 100644 apps/recnet/src/app/admin/AdminPanelNav.tsx create mode 100644 apps/recnet/src/app/admin/layout.tsx create mode 100644 apps/recnet/src/app/admin/page.tsx diff --git a/apps/recnet/src/app/Headerbar.tsx b/apps/recnet/src/app/Headerbar.tsx index 32e86457..ee77a78e 100644 --- a/apps/recnet/src/app/Headerbar.tsx +++ b/apps/recnet/src/app/Headerbar.tsx @@ -39,6 +39,11 @@ export function UserDropdown({ user }: { user: User }) { Profile + {user.role && user.role === "admin" ? ( + + Admin Panel + + ) : null} Log out diff --git a/apps/recnet/src/app/admin/AdminPanelNav.tsx b/apps/recnet/src/app/admin/AdminPanelNav.tsx new file mode 100644 index 00000000..49fc6ae3 --- /dev/null +++ b/apps/recnet/src/app/admin/AdminPanelNav.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { cn } from "@/utils/cn"; +import { Text } from "@radix-ui/themes"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export function AdminPanelNavItem(props: { route: string; label: string }) { + const { route, label } = props; + const pathname = usePathname(); + const isActive = `/admin/${route}` === pathname; + + return ( + +
+ {label} +
+ + ); +} + +export function AdminPageNavigator() { + return ( +
+
+ + Stats + +
+ +
+
+
+ + Email + +
+ +
+
+
+ + Invite code + +
+ + +
+
+
+ ); +} diff --git a/apps/recnet/src/app/admin/layout.tsx b/apps/recnet/src/app/admin/layout.tsx new file mode 100644 index 00000000..200c1c28 --- /dev/null +++ b/apps/recnet/src/app/admin/layout.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { cn } from "@/utils/cn"; +import { AdminPageNavigator } from "@/app/admin/AdminPanelNav"; + +export default function Layout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ + {children} +
+ ); +} diff --git a/apps/recnet/src/app/admin/page.tsx b/apps/recnet/src/app/admin/page.tsx new file mode 100644 index 00000000..d63ad8d7 --- /dev/null +++ b/apps/recnet/src/app/admin/page.tsx @@ -0,0 +1,7 @@ +import { cn } from "@/utils/cn"; + +export default function AdminPage() { + return ( +
Admin Page
+ ); +} diff --git a/apps/recnet/src/types/user.ts b/apps/recnet/src/types/user.ts index b81e138b..b03a5782 100644 --- a/apps/recnet/src/types/user.ts +++ b/apps/recnet/src/types/user.ts @@ -15,6 +15,7 @@ export const UserSchema = z.object({ username: z.string(), seed: z.string(), id: z.string(), + role: z.string().optional(), }); export type User = z.infer; From 36ea347b6f38d905e2ecbb886096e67e17bcbcaf Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 29 Feb 2024 00:24:10 -0500 Subject: [PATCH 02/41] refactor: refactor admin panel components --- apps/recnet/src/app/admin/AdminPanelNav.tsx | 120 +++++++++++++------- apps/recnet/src/app/admin/layout.tsx | 4 +- 2 files changed, 78 insertions(+), 46 deletions(-) diff --git a/apps/recnet/src/app/admin/AdminPanelNav.tsx b/apps/recnet/src/app/admin/AdminPanelNav.tsx index 49fc6ae3..39ac774a 100644 --- a/apps/recnet/src/app/admin/AdminPanelNav.tsx +++ b/apps/recnet/src/app/admin/AdminPanelNav.tsx @@ -4,8 +4,47 @@ import { cn } from "@/utils/cn"; import { Text } from "@radix-ui/themes"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { createContext, useContext } from "react"; -export function AdminPanelNavItem(props: { route: string; label: string }) { +const AdminPanelNavContext = createContext({}); + +function useAdminPanelNavContext() { + const context = useContext(AdminPanelNavContext); + + if (!context) { + throw new Error( + "Child components of AdminPanelNav cannot be rendered outside the AdminPanelNav component." + ); + } + + return context; +} + +function AdminPanelNav({ children }: { children: React.ReactNode }) { + return ( + +
+ {children} +
+
+ ); +} + +function NavItem(props: { route: string; label: string }) { + useAdminPanelNavContext(); const { route, label } = props; const pathname = usePathname(); const isActive = `/admin/${route}` === pathname; @@ -25,51 +64,44 @@ export function AdminPanelNavItem(props: { route: string; label: string }) { ); } +AdminPanelNav.Item = NavItem; + +function NavSection({ + children, + label, +}: { + children: React.ReactNode; + label: string; +}) { + useAdminPanelNavContext(); -export function AdminPageNavigator() { return ( -
-
- - Stats - -
- -
-
-
- - Email - -
- -
-
-
- - Invite code - -
- - -
-
+
+ + {label} + +
{children}
); } +AdminPanelNav.Section = NavSection; + +export function AdminPanelNavbar() { + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/recnet/src/app/admin/layout.tsx b/apps/recnet/src/app/admin/layout.tsx index 200c1c28..30392d0c 100644 --- a/apps/recnet/src/app/admin/layout.tsx +++ b/apps/recnet/src/app/admin/layout.tsx @@ -1,6 +1,6 @@ import React from "react"; import { cn } from "@/utils/cn"; -import { AdminPageNavigator } from "@/app/admin/AdminPanelNav"; +import { AdminPanelNavbar } from "@/app/admin/AdminPanelNav"; export default function Layout({ children, @@ -18,7 +18,7 @@ export default function Layout({ `min-h-[90svh]` )} > - + {children}
); From 61d66744e4fd84c513b4be870168ed488220502e Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 29 Feb 2024 00:28:35 -0500 Subject: [PATCH 03/41] feat(admin page): add page protection --- apps/recnet/src/app/admin/layout.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/recnet/src/app/admin/layout.tsx b/apps/recnet/src/app/admin/layout.tsx index 30392d0c..2038ef28 100644 --- a/apps/recnet/src/app/admin/layout.tsx +++ b/apps/recnet/src/app/admin/layout.tsx @@ -1,12 +1,20 @@ import React from "react"; import { cn } from "@/utils/cn"; import { AdminPanelNavbar } from "@/app/admin/AdminPanelNav"; +import { notFound } from "next/navigation"; +import { getUserServerSide } from "@/utils/getUserServerSide"; -export default function Layout({ +export default async function Layout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const user = await getUserServerSide(); + + if (!user || user?.role !== "admin") { + notFound(); + } + return (
Date: Thu, 29 Feb 2024 00:47:09 -0500 Subject: [PATCH 04/41] Add margin top to AdminPanelNav and change text color in NavItem --- apps/recnet/src/app/admin/AdminPanelNav.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/recnet/src/app/admin/AdminPanelNav.tsx b/apps/recnet/src/app/admin/AdminPanelNav.tsx index 39ac774a..4da6cd2f 100644 --- a/apps/recnet/src/app/admin/AdminPanelNav.tsx +++ b/apps/recnet/src/app/admin/AdminPanelNav.tsx @@ -34,7 +34,8 @@ function AdminPanelNav({ children }: { children: React.ReactNode }) { "hidden", "md:flex", "flex-col", - "gap-y-4" + "gap-y-4", + "mt-4" )} > {children} @@ -54,6 +55,7 @@ function NavItem(props: { route: string; label: string }) {
Date: Thu, 29 Feb 2024 01:30:50 -0500 Subject: [PATCH 05/41] refactor: refactor code to use parsed env vars --- apps/recnet/src/app/api/sendDigest/route.ts | 3 ++- apps/recnet/src/app/layout.tsx | 9 ++++++--- apps/recnet/src/clientEnv.ts | 4 ++++ apps/recnet/src/firebase/admin.ts | 7 ++++--- apps/recnet/src/utils/zodFetch.ts | 3 ++- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/recnet/src/app/api/sendDigest/route.ts b/apps/recnet/src/app/api/sendDigest/route.ts index c754ee6f..e198464d 100644 --- a/apps/recnet/src/app/api/sendDigest/route.ts +++ b/apps/recnet/src/app/api/sendDigest/route.ts @@ -7,12 +7,13 @@ import { notEmpty } from "@/utils/notEmpty"; import WeeklyDigest from "../../../../emails/WeeklyDigest"; import { render } from "@react-email/render"; import { NextRequest, NextResponse } from "next/server"; +import { serverEnv } from "@/serverEnv"; // const TEST_USER_IDS = ["GoXnHBhgK8QhcZpki0la"]; export async function GET(request: NextRequest) { if ( - request.headers.get("Authorization") !== `Bearer ${process.env.CRON_SECRET}` + request.headers.get("Authorization") !== `Bearer ${serverEnv.CRON_SECRET}` ) { return NextResponse.json({ ok: false }); } diff --git a/apps/recnet/src/app/layout.tsx b/apps/recnet/src/app/layout.tsx index db7e7240..764863a6 100644 --- a/apps/recnet/src/app/layout.tsx +++ b/apps/recnet/src/app/layout.tsx @@ -12,6 +12,7 @@ import "tailwindcss/tailwind.css"; import { MobileNavigator } from "./MobileNavigator"; import { ProgressbarProvider } from "./Progressbar"; import { GoogleAnalytics } from "@next/third-parties/google"; +import { clientEnv } from "@/clientEnv"; const sfpro = localFont({ src: [ @@ -122,9 +123,11 @@ export default async function RootLayout({ const user = await getUserServerSide(); return ( - + {clientEnv.NEXT_PUBLIC_ENV === "prod" ? ( + + ) : null} diff --git a/apps/recnet/src/clientEnv.ts b/apps/recnet/src/clientEnv.ts index 41cbb362..bf8bd1d8 100644 --- a/apps/recnet/src/clientEnv.ts +++ b/apps/recnet/src/clientEnv.ts @@ -6,6 +6,8 @@ export const clientEnvSchema = z.object({ NEXT_PUBLIC_FIREBASE_PROJECT_ID: z.string(), NEXT_PUBLIC_FIREBASE_APP_ID: z.string(), NEXT_PUBLIC_GA_TRACKING_ID: z.string(), + NEXT_PUBLIC_ENV: z.string(), + NEXT_PUBLIC_BASE_URL: z.string(), }); const clientEnvRes = clientEnvSchema.safeParse({ @@ -15,6 +17,8 @@ const clientEnvRes = clientEnvSchema.safeParse({ NEXT_PUBLIC_FIREBASE_PROJECT_ID: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, NEXT_PUBLIC_FIREBASE_APP_ID: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, NEXT_PUBLIC_GA_TRACKING_ID: process.env.NEXT_PUBLIC_GA_TRACKING_ID, + NEXT_PUBLIC_ENV: process.env.NEXT_PUBLIC_ENV, + NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, }); if (!clientEnvRes.success) { diff --git a/apps/recnet/src/firebase/admin.ts b/apps/recnet/src/firebase/admin.ts index 45600e76..f999eab1 100644 --- a/apps/recnet/src/firebase/admin.ts +++ b/apps/recnet/src/firebase/admin.ts @@ -1,11 +1,12 @@ import * as admin from "firebase-admin"; +import { serverEnv } from "@/serverEnv"; if (!admin.apps.length) { admin.initializeApp({ credential: admin.credential.cert({ - projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, - clientEmail: process.env.FIREBASE_CLIENT_EMAIL, - privateKey: process.env.FIREBASE_PRIVATE_KEY!.replace(/\\n/gm, "\n"), + projectId: serverEnv.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + clientEmail: serverEnv.FIREBASE_CLIENT_EMAIL, + privateKey: serverEnv.FIREBASE_PRIVATE_KEY, }), }); } diff --git a/apps/recnet/src/utils/zodFetch.ts b/apps/recnet/src/utils/zodFetch.ts index d10d38db..c67395cd 100644 --- a/apps/recnet/src/utils/zodFetch.ts +++ b/apps/recnet/src/utils/zodFetch.ts @@ -1,10 +1,11 @@ import { createZodFetcher } from "zod-fetch"; import { z } from "zod"; +import { clientEnv } from "@/clientEnv"; const IS_SERVER = typeof window === "undefined"; function getURL(path: string) { const baseURL = IS_SERVER - ? process.env.NEXT_PUBLIC_BASE_URL! + ? clientEnv.NEXT_PUBLIC_BASE_URL : window.location.origin; return new URL(path, baseURL).toString(); } From f36d44c870913e3394a8283e7d9d662cb6f1d671 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 29 Feb 2024 16:52:50 -0500 Subject: [PATCH 06/41] fix: highlight nav item when user is on corresponding page --- apps/recnet/src/app/admin/AdminPanelNav.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/recnet/src/app/admin/AdminPanelNav.tsx b/apps/recnet/src/app/admin/AdminPanelNav.tsx index 4da6cd2f..8e3d4a42 100644 --- a/apps/recnet/src/app/admin/AdminPanelNav.tsx +++ b/apps/recnet/src/app/admin/AdminPanelNav.tsx @@ -35,7 +35,7 @@ function AdminPanelNav({ children }: { children: React.ReactNode }) { "md:flex", "flex-col", "gap-y-4", - "mt-4" + "pt-8" )} > {children} @@ -48,7 +48,7 @@ function NavItem(props: { route: string; label: string }) { useAdminPanelNavContext(); const { route, label } = props; const pathname = usePathname(); - const isActive = `/admin/${route}` === pathname; + const isActive = pathname === `/admin/${route}`; return ( @@ -57,7 +57,7 @@ function NavItem(props: { route: string; label: string }) { "px-3 py-2 rounded-[999px] hover:bg-accentA-3 cursor-pointer transition-all ease-in-out duration-200", "text-gray-11", { - "bg-blue-a4": isActive, + "bg-accentA-4": isActive, } )} > From 4493481669498d9e78de6dca8be35e5f8caeb7fb Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 29 Feb 2024 17:11:49 -0500 Subject: [PATCH 07/41] Update Headerbar and AdminPanelNav styles --- apps/recnet/src/app/Headerbar.tsx | 3 +- apps/recnet/src/app/admin/AdminPanelNav.tsx | 46 ++++++++++++--------- apps/recnet/src/app/admin/layout.tsx | 2 +- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/apps/recnet/src/app/Headerbar.tsx b/apps/recnet/src/app/Headerbar.tsx index ee77a78e..31bf7e21 100644 --- a/apps/recnet/src/app/Headerbar.tsx +++ b/apps/recnet/src/app/Headerbar.tsx @@ -125,7 +125,8 @@ export function Headerbar() { "border-b-[1px]", "border-slate-8", "sticky", - "top-0" + "top-0", + "z-[1000]" )} > diff --git a/apps/recnet/src/app/admin/AdminPanelNav.tsx b/apps/recnet/src/app/admin/AdminPanelNav.tsx index 8e3d4a42..1c12121f 100644 --- a/apps/recnet/src/app/admin/AdminPanelNav.tsx +++ b/apps/recnet/src/app/admin/AdminPanelNav.tsx @@ -22,25 +22,33 @@ function useAdminPanelNavContext() { function AdminPanelNav({ children }: { children: React.ReactNode }) { return ( - -
- {children} -
-
+
+ +
+ {children} +
+
+
); } diff --git a/apps/recnet/src/app/admin/layout.tsx b/apps/recnet/src/app/admin/layout.tsx index 2038ef28..30e782ef 100644 --- a/apps/recnet/src/app/admin/layout.tsx +++ b/apps/recnet/src/app/admin/layout.tsx @@ -27,7 +27,7 @@ export default async function Layout({ )} > - {children} +
{children}
); } From 481c9837da0d0141ccf2ea92b51866c752c8b85b Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 29 Feb 2024 18:47:15 -0500 Subject: [PATCH 08/41] feat: add stat box component --- apps/recnet/src/app/admin/stats/StatBox.tsx | 56 +++++++++++++++++ .../src/app/admin/stats/user-rec/page.tsx | 60 +++++++++++++++++++ apps/recnet/src/firebase/admin.ts | 1 + apps/recnet/src/utils/withSuspense.tsx | 16 +++++ 4 files changed, 133 insertions(+) create mode 100644 apps/recnet/src/app/admin/stats/StatBox.tsx create mode 100644 apps/recnet/src/app/admin/stats/user-rec/page.tsx create mode 100644 apps/recnet/src/utils/withSuspense.tsx diff --git a/apps/recnet/src/app/admin/stats/StatBox.tsx b/apps/recnet/src/app/admin/stats/StatBox.tsx new file mode 100644 index 00000000..477265fb --- /dev/null +++ b/apps/recnet/src/app/admin/stats/StatBox.tsx @@ -0,0 +1,56 @@ +import { SkeletonText } from "@/components/Skeleton"; +import { cn } from "@/utils/cn"; +import { Text } from "@radix-ui/themes"; + +export function StatBox({ + children, + title, + icon, +}: { + children: React.ReactNode; + title: React.ReactNode; + icon?: React.ReactNode; +}) { + return ( +
+
+ {icon} + {title} +
+
+ {children} +
+
+ ); +} + +export function StatBoxSkeleton() { + return ( +
+ + +
+ ); +} diff --git a/apps/recnet/src/app/admin/stats/user-rec/page.tsx b/apps/recnet/src/app/admin/stats/user-rec/page.tsx new file mode 100644 index 00000000..08e1f214 --- /dev/null +++ b/apps/recnet/src/app/admin/stats/user-rec/page.tsx @@ -0,0 +1,60 @@ +import { db } from "@/firebase/admin"; +import { StatBox, StatBoxSkeleton } from "@/app/admin/stats/StatBox"; +import { Pencil1Icon, PersonIcon } from "@radix-ui/react-icons"; +import { getLatestCutOff } from "@/utils/date"; +import { Timestamp } from "firebase-admin/firestore"; +import { withSuspense } from "@/utils/withSuspense"; + +const CurrentUserCount = withSuspense( + async () => { + const users = await db.collection("users").get(); + const userCount = users.size; + return ( + }> + {userCount} + + ); + }, + +); + +const RecCount = withSuspense( + async () => { + const recs = await db.collection("recommendations").get(); + const recCount = recs.size; + return ( + }> + {recCount} + + ); + }, + +); + +const RecsThisCycle = withSuspense( + async () => { + const cutOff = getLatestCutOff(); + const recsThisCycle = await db + .collection("recommendations") + .where("cutoff", "==", Timestamp.fromMillis(cutOff.getTime())) + .get(); + return ( + }> + {recsThisCycle.size} + + ); + }, + +); + +export default async function UserRecStats() { + return ( +
+
+ + + +
+
+ ); +} diff --git a/apps/recnet/src/firebase/admin.ts b/apps/recnet/src/firebase/admin.ts index f999eab1..277b1a56 100644 --- a/apps/recnet/src/firebase/admin.ts +++ b/apps/recnet/src/firebase/admin.ts @@ -1,3 +1,4 @@ +import "server-only"; import * as admin from "firebase-admin"; import { serverEnv } from "@/serverEnv"; diff --git a/apps/recnet/src/utils/withSuspense.tsx b/apps/recnet/src/utils/withSuspense.tsx new file mode 100644 index 00000000..e76859e8 --- /dev/null +++ b/apps/recnet/src/utils/withSuspense.tsx @@ -0,0 +1,16 @@ +import React, { ComponentType, Suspense, SuspenseProps } from "react"; + +export function withSuspense

( + WrappedComponent: ComponentType

, + fallback: SuspenseProps["fallback"] = null +) { + function ComponentWithSuspense(props: P) { + return ( + + + + ); + } + + return ComponentWithSuspense; +} From 88642c3e5a111134b02ca491dbe283e28f03a19c Mon Sep 17 00:00:00 2001 From: swh00tw Date: Thu, 29 Feb 2024 18:47:59 -0500 Subject: [PATCH 09/41] feat: add comment --- apps/recnet/src/utils/withSuspense.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/recnet/src/utils/withSuspense.tsx b/apps/recnet/src/utils/withSuspense.tsx index e76859e8..0e1861dd 100644 --- a/apps/recnet/src/utils/withSuspense.tsx +++ b/apps/recnet/src/utils/withSuspense.tsx @@ -1,5 +1,7 @@ import React, { ComponentType, Suspense, SuspenseProps } from "react"; +// React Server Component HOC for Suspense +// ref: https://gist.github.com/emeraldsanto/43ae63eff64cafbab58d6e4d740deabf export function withSuspense

( WrappedComponent: ComponentType

, fallback: SuspenseProps["fallback"] = null From e2efcdc014e7de82065787ec21b9bd6c51293f7a Mon Sep 17 00:00:00 2001 From: swh00tw Date: Fri, 1 Mar 2024 00:18:56 -0500 Subject: [PATCH 10/41] feat(admin panel): implement RecsCycleBarChart component --- apps/recnet/src/app/admin/stats/StatBox.tsx | 16 +- .../stats/user-rec/RecsCycleBarChart.tsx | 90 ++++ .../src/app/admin/stats/user-rec/page.tsx | 50 +- package.json | 4 + pnpm-lock.yaml | 440 +++++++++++++++++- 5 files changed, 569 insertions(+), 31 deletions(-) create mode 100644 apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx diff --git a/apps/recnet/src/app/admin/stats/StatBox.tsx b/apps/recnet/src/app/admin/stats/StatBox.tsx index 477265fb..24d4dc78 100644 --- a/apps/recnet/src/app/admin/stats/StatBox.tsx +++ b/apps/recnet/src/app/admin/stats/StatBox.tsx @@ -1,15 +1,16 @@ import { SkeletonText } from "@/components/Skeleton"; import { cn } from "@/utils/cn"; -import { Text } from "@radix-ui/themes"; export function StatBox({ children, title, icon, + className, }: { children: React.ReactNode; title: React.ReactNode; icon?: React.ReactNode; + className?: string; }) { return (

-
+
{icon} {title}
-
- {children} +
+ {children}
); diff --git a/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx b/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx new file mode 100644 index 00000000..766a5013 --- /dev/null +++ b/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useMemo } from "react"; +import { scaleUtc, scaleLinear } from "@visx/scale"; +import { ParentSize } from "@visx/responsive"; +import { Bar } from "@visx/shape"; +import { Group } from "@visx/group"; + +type Timestamp = string; + +interface RecsCycleBarChartProps { + parentWidth: number; + parentHeight: number; + data: Record; +} + +const verticalMargin = 70; + +function BarChart(props: RecsCycleBarChartProps) { + const { parentWidth, parentHeight, data } = props; + // bounds + const xMax = parentWidth; + const yMax = parentHeight - verticalMargin; + + // data + const timestamps = Object.keys(data).map((ts) => parseInt(ts, 10)); + + const xScale = useMemo(() => { + return scaleUtc({ + domain: [ + new Date(Math.min(...timestamps)), + new Date(Math.max(...timestamps)), + ], + range: [0, xMax], + }); + }, [xMax, timestamps]); + + const yScale = useMemo(() => { + return scaleLinear({ + domain: [0, Math.max(...Object.values(data))], + range: [yMax, 0], + }); + }, [yMax, data]); + + return ( + + + {Object.keys(data) + .map((key) => { + const ts = parseInt(key, 10); + return { + ts, + count: data[key], + }; + }) + .map((d) => { + const barWidth = 20; + const barHeight = yMax - (yScale(d.count) ?? 0); + const barX = xScale(new Date(d.ts)); + const barY = yMax - barHeight; + return ( + {}} + /> + ); + })} + + + ); +} + +export function RecsCycleBarChart(props: Pick) { + return ( + + {(parent) => ( + + )} + + ); +} diff --git a/apps/recnet/src/app/admin/stats/user-rec/page.tsx b/apps/recnet/src/app/admin/stats/user-rec/page.tsx index 08e1f214..5a4f1e81 100644 --- a/apps/recnet/src/app/admin/stats/user-rec/page.tsx +++ b/apps/recnet/src/app/admin/stats/user-rec/page.tsx @@ -1,9 +1,13 @@ import { db } from "@/firebase/admin"; import { StatBox, StatBoxSkeleton } from "@/app/admin/stats/StatBox"; import { Pencil1Icon, PersonIcon } from "@radix-ui/react-icons"; -import { getLatestCutOff } from "@/utils/date"; +import { getDateFromFirebaseTimestamp, getLatestCutOff } from "@/utils/date"; import { Timestamp } from "firebase-admin/firestore"; import { withSuspense } from "@/utils/withSuspense"; +import groupBy from "lodash.groupby"; +import { RecSchema } from "@/types/rec"; +import { notEmpty } from "@/utils/notEmpty"; +import { RecsCycleBarChart } from "./RecsCycleBarChart"; const CurrentUserCount = withSuspense( async () => { @@ -47,14 +51,56 @@ const RecsThisCycle = withSuspense( ); +const RecsBarChart = withSuspense( + async () => { + const recs = await db.collection("recommendations").get(); + const filteredRecs = recs.docs + .map((doc) => { + const data = doc.data(); + // parse by rec schema + const res = RecSchema.safeParse({ ...data, id: doc.id }); + if (res.success) { + return res.data; + } else { + console.error("Failed to parse rec", res.error); + return null; + } + }) + .filter(notEmpty); + const recsGroupByCycle = groupBy(filteredRecs, (doc) => { + const date = getDateFromFirebaseTimestamp(doc.cutoff); + return date.getTime(); + }); + const recCountByCycle = Object.keys(recsGroupByCycle).reduce( + (acc, key) => ({ + ...acc, + [key]: recsGroupByCycle[key].length, + }), + {} + ); + + return ( + } + className="h-[300px] w-[40%] min-w-[500px]" + > + + + ); + }, + +); + export default async function UserRecStats() { return ( -
+
+
); } diff --git a/package.json b/package.json index eed8d65f..db004be7 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "@radix-ui/themes": "^2.0.3", "@react-email/components": "0.0.14", "@react-email/render": "^0.0.12", + "@visx/group": "^3.3.0", + "@visx/responsive": "^3.3.0", + "@visx/scale": "^3.5.0", + "@visx/shape": "^3.5.0", "chance": "^1.1.11", "clsx": "^2.1.0", "firebase": "^10.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8cae272..fc61f0d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + dependencies: '@hookform/resolvers': specifier: ^3.3.4 @@ -22,6 +26,18 @@ dependencies: '@react-email/render': specifier: ^0.0.12 version: 0.0.12 + '@visx/group': + specifier: ^3.3.0 + version: 3.3.0(react@18.2.0) + '@visx/responsive': + specifier: ^3.3.0 + version: 3.3.0(react@18.2.0) + '@visx/scale': + specifier: ^3.5.0 + version: 3.5.0 + '@visx/shape': + specifier: ^3.5.0 + version: 3.5.0(react@18.2.0) chance: specifier: ^1.1.11 version: 1.1.11 @@ -1756,6 +1772,7 @@ packages: /@emotion/memoize@0.7.4: resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} + requiresBuild: true dev: false optional: true @@ -1773,6 +1790,7 @@ packages: cpu: [ppc64] os: [aix] requiresBuild: true + dev: false optional: true /@esbuild/android-arm64@0.19.11: @@ -1781,6 +1799,7 @@ packages: cpu: [arm64] os: [android] requiresBuild: true + dev: false optional: true /@esbuild/android-arm@0.19.11: @@ -1789,6 +1808,7 @@ packages: cpu: [arm] os: [android] requiresBuild: true + dev: false optional: true /@esbuild/android-x64@0.19.11: @@ -1797,6 +1817,7 @@ packages: cpu: [x64] os: [android] requiresBuild: true + dev: false optional: true /@esbuild/darwin-arm64@0.19.11: @@ -1805,6 +1826,7 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true + dev: false optional: true /@esbuild/darwin-x64@0.19.11: @@ -1813,6 +1835,7 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true + dev: false optional: true /@esbuild/freebsd-arm64@0.19.11: @@ -1821,6 +1844,7 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true + dev: false optional: true /@esbuild/freebsd-x64@0.19.11: @@ -1829,6 +1853,7 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true + dev: false optional: true /@esbuild/linux-arm64@0.19.11: @@ -1837,6 +1862,7 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-arm@0.19.11: @@ -1845,6 +1871,7 @@ packages: cpu: [arm] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-ia32@0.19.11: @@ -1853,6 +1880,7 @@ packages: cpu: [ia32] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-loong64@0.19.11: @@ -1861,6 +1889,7 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-mips64el@0.19.11: @@ -1869,6 +1898,7 @@ packages: cpu: [mips64el] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-ppc64@0.19.11: @@ -1877,6 +1907,7 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-riscv64@0.19.11: @@ -1885,6 +1916,7 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-s390x@0.19.11: @@ -1893,6 +1925,7 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-x64@0.19.11: @@ -1901,6 +1934,7 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/netbsd-x64@0.19.11: @@ -1909,6 +1943,7 @@ packages: cpu: [x64] os: [netbsd] requiresBuild: true + dev: false optional: true /@esbuild/openbsd-x64@0.19.11: @@ -1917,6 +1952,7 @@ packages: cpu: [x64] os: [openbsd] requiresBuild: true + dev: false optional: true /@esbuild/sunos-x64@0.19.11: @@ -1925,6 +1961,7 @@ packages: cpu: [x64] os: [sunos] requiresBuild: true + dev: false optional: true /@esbuild/win32-arm64@0.19.11: @@ -1933,6 +1970,7 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true + dev: false optional: true /@esbuild/win32-ia32@0.19.11: @@ -1941,6 +1979,7 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true + dev: false optional: true /@esbuild/win32-x64@0.19.11: @@ -1949,6 +1988,7 @@ packages: cpu: [x64] os: [win32] requiresBuild: true + dev: false optional: true /@eslint-community/eslint-utils@4.4.0(eslint@8.48.0): @@ -2528,6 +2568,7 @@ packages: /@google-cloud/paginator@5.0.0: resolution: {integrity: sha512-87aeg6QQcEPxGCOthnpUjvw4xAZ57G7pL8FS0C4e/81fr3FjkpUpibf1s2v5XGyGhUVGF4Jfg7yEcxqn2iUw1w==} engines: {node: '>=14.0.0'} + requiresBuild: true dependencies: arrify: 2.0.1 extend: 3.0.2 @@ -2537,12 +2578,14 @@ packages: /@google-cloud/projectify@4.0.0: resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==} engines: {node: '>=14.0.0'} + requiresBuild: true dev: false optional: true /@google-cloud/promisify@4.0.0: resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==} engines: {node: '>=14'} + requiresBuild: true dev: false optional: true @@ -3763,11 +3806,11 @@ packages: style-loader: 3.3.4(webpack@5.90.1) stylus: 0.59.0 stylus-loader: 7.1.3(stylus@0.59.0)(webpack@5.90.1) - terser-webpack-plugin: 5.3.10(@swc/core@1.3.101)(esbuild@0.19.11)(webpack@5.90.1) + terser-webpack-plugin: 5.3.10(@swc/core@1.3.85)(webpack@5.90.1) ts-loader: 9.5.1(typescript@5.3.3)(webpack@5.90.1) tsconfig-paths-webpack-plugin: 4.0.0 tslib: 2.6.2 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) webpack-dev-server: 4.15.1(webpack@5.90.1) webpack-node-externals: 3.0.0 webpack-subresource-integrity: 5.1.0(webpack@5.90.1) @@ -3842,22 +3885,27 @@ packages: /@protobufjs/aspromise@1.1.2: resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + requiresBuild: true dev: false /@protobufjs/base64@1.1.2: resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + requiresBuild: true dev: false /@protobufjs/codegen@2.0.4: resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + requiresBuild: true dev: false /@protobufjs/eventemitter@1.1.0: resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + requiresBuild: true dev: false /@protobufjs/fetch@1.1.0: resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + requiresBuild: true dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/inquire': 1.1.0 @@ -3865,22 +3913,27 @@ packages: /@protobufjs/float@1.0.2: resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + requiresBuild: true dev: false /@protobufjs/inquire@1.1.0: resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + requiresBuild: true dev: false /@protobufjs/path@1.1.2: resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + requiresBuild: true dev: false /@protobufjs/pool@1.1.0: resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + requiresBuild: true dev: false /@protobufjs/utf8@1.1.0: resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + requiresBuild: true dev: false /@radix-ui/colors@1.0.1: @@ -5662,6 +5715,7 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true + dev: false optional: true /@swc/core-darwin-arm64@1.3.85: @@ -5678,6 +5732,7 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true + dev: false optional: true /@swc/core-darwin-x64@1.3.85: @@ -5694,6 +5749,7 @@ packages: cpu: [arm] os: [linux] requiresBuild: true + dev: false optional: true /@swc/core-linux-arm-gnueabihf@1.3.85: @@ -5710,6 +5766,7 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: false optional: true /@swc/core-linux-arm64-gnu@1.3.85: @@ -5726,6 +5783,7 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: false optional: true /@swc/core-linux-arm64-musl@1.3.85: @@ -5742,6 +5800,7 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: false optional: true /@swc/core-linux-x64-gnu@1.3.85: @@ -5758,6 +5817,7 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: false optional: true /@swc/core-linux-x64-musl@1.3.85: @@ -5774,6 +5834,7 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true + dev: false optional: true /@swc/core-win32-arm64-msvc@1.3.85: @@ -5790,6 +5851,7 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true + dev: false optional: true /@swc/core-win32-ia32-msvc@1.3.85: @@ -5806,6 +5868,7 @@ packages: cpu: [x64] os: [win32] requiresBuild: true + dev: false optional: true /@swc/core-win32-x64-msvc@1.3.85: @@ -5840,6 +5903,7 @@ packages: '@swc/core-win32-arm64-msvc': 1.3.101 '@swc/core-win32-ia32-msvc': 1.3.101 '@swc/core-win32-x64-msvc': 1.3.101 + dev: false /@swc/core@1.3.85(@swc/helpers@0.5.2): resolution: {integrity: sha512-qnoxp+2O0GtvRdYnXgR1v8J7iymGGYpx6f6yCK9KxipOZOjrlKILFANYlghQxZyPUfXwK++TFxfSlX4r9wK+kg==} @@ -5867,6 +5931,7 @@ packages: /@swc/counter@0.1.3: resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + dev: false /@swc/helpers@0.5.2: resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} @@ -5940,6 +6005,7 @@ packages: /@types/caseless@0.12.5: resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + requiresBuild: true dev: false optional: true @@ -5969,6 +6035,58 @@ packages: '@types/node': 20.11.17 dev: false + /@types/d3-array@3.0.3: + resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} + dev: false + + /@types/d3-color@3.1.0: + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + dev: false + + /@types/d3-delaunay@6.0.1: + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + dev: false + + /@types/d3-format@3.0.1: + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + dev: false + + /@types/d3-geo@3.1.0: + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + dependencies: + '@types/geojson': 7946.0.14 + dev: false + + /@types/d3-interpolate@3.0.1: + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + dependencies: + '@types/d3-color': 3.1.0 + dev: false + + /@types/d3-path@1.0.11: + resolution: {integrity: sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==} + dev: false + + /@types/d3-scale@4.0.2: + resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + dependencies: + '@types/d3-time': 3.0.0 + dev: false + + /@types/d3-shape@1.3.12: + resolution: {integrity: sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==} + dependencies: + '@types/d3-path': 1.0.11 + dev: false + + /@types/d3-time-format@2.1.0: + resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==} + dev: false + + /@types/d3-time@3.0.0: + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + dev: false + /@types/eslint-scope@3.7.7: resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} dependencies: @@ -6000,6 +6118,10 @@ packages: '@types/qs': 6.9.11 '@types/serve-static': 1.15.5 + /@types/geojson@7946.0.14: + resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==} + dev: false + /@types/graceful-fs@4.1.9: resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} dependencies: @@ -6067,10 +6189,10 @@ packages: /@types/lodash@4.14.202: resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} - dev: true /@types/long@4.0.2: resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + requiresBuild: true dev: false optional: true @@ -6141,6 +6263,7 @@ packages: /@types/request@2.48.12: resolution: {integrity: sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==} + requiresBuild: true dependencies: '@types/caseless': 0.12.5 '@types/node': 20.11.17 @@ -6480,6 +6603,86 @@ packages: eslint-visitor-keys: 3.4.3 dev: true + /@visx/curve@3.3.0: + resolution: {integrity: sha512-G1l1rzGWwIs8ka3mBhO/gj8uYK6XdU/3bwRSoiZ+MockMahQFPog0bUkuVgPwwzPSJfsA/E5u53Y/DNesnHQxg==} + dependencies: + '@types/d3-shape': 1.3.12 + d3-shape: 1.3.7 + dev: false + + /@visx/group@3.3.0(react@18.2.0): + resolution: {integrity: sha512-yKepDKwJqlzvnvPS0yDuW13XNrYJE4xzT6xM7J++441nu6IybWWwextyap8ey+kU651cYDb+q1Oi6aHvQwyEyw==} + peerDependencies: + react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/react': 18.2.33 + classnames: 2.5.1 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@visx/responsive@3.3.0(react@18.2.0): + resolution: {integrity: sha512-Y3Bgrh6cJ760lG6yXsxJRNCmYZAHKQqSmTG2qxJ8yImledieGEqI0ZizXJgFkxoBaZK5gSMvFsmFWKtf7a86kQ==} + peerDependencies: + react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/lodash': 4.14.202 + '@types/react': 18.2.33 + lodash: 4.17.21 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@visx/scale@3.5.0: + resolution: {integrity: sha512-xo3zrXV2IZxrMq9Y9RUVJUpd93h3NO/r/y3GVi5F9AsbOzOhsLIbsPkunhO9mpUSR8LZ9TiumLEBrY+3frRBSg==} + dependencies: + '@visx/vendor': 3.5.0 + dev: false + + /@visx/shape@3.5.0(react@18.2.0): + resolution: {integrity: sha512-DP3t9jBQ7dSE3e6ptA1xO4QAIGxO55GrY/6P+S6YREuQGjZgq20TLYLAsiaoPEzFSS4tp0m12ZTPivWhU2VBTw==} + peerDependencies: + react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/d3-path': 1.0.11 + '@types/d3-shape': 1.3.12 + '@types/lodash': 4.14.202 + '@types/react': 18.2.33 + '@visx/curve': 3.3.0 + '@visx/group': 3.3.0(react@18.2.0) + '@visx/scale': 3.5.0 + classnames: 2.5.1 + d3-path: 1.0.9 + d3-shape: 1.3.7 + lodash: 4.17.21 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@visx/vendor@3.5.0: + resolution: {integrity: sha512-yt3SEZRVmt36+APsCISSO9eSOtzQkBjt+QRxNRzcTWuzwMAaF3PHCCSe31++kkpgY9yFoF+Gfes1TBe5NlETiQ==} + dependencies: + '@types/d3-array': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-delaunay': 6.0.1 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-interpolate': 3.0.1 + '@types/d3-scale': 4.0.2 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 + dev: false + /@webassemblyjs/ast@1.11.6: resolution: {integrity: sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==} dependencies: @@ -6609,6 +6812,7 @@ packages: /abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + requiresBuild: true dependencies: event-target-shim: 5.0.1 dev: false @@ -6667,6 +6871,7 @@ packages: /agent-base@7.1.0: resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} engines: {node: '>= 14'} + requiresBuild: true dependencies: debug: 4.3.4 transitivePeerDependencies: @@ -6921,6 +7126,7 @@ packages: /arrify@2.0.1: resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} engines: {node: '>=8'} + requiresBuild: true dev: false optional: true @@ -6930,6 +7136,7 @@ packages: /async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + requiresBuild: true dependencies: retry: 0.13.1 dev: false @@ -7057,7 +7264,7 @@ packages: '@babel/core': 7.23.9 find-cache-dir: 4.0.0 schema-utils: 4.2.0 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /babel-plugin-const-enum@1.2.0(@babel/core@7.23.9): @@ -7219,6 +7426,7 @@ packages: /bignumber.js@9.1.2: resolution: {integrity: sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==} + requiresBuild: true dev: false optional: true @@ -7720,7 +7928,7 @@ packages: normalize-path: 3.0.0 schema-utils: 4.2.0 serialize-javascript: 6.0.2 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /core-js-compat@3.35.1: @@ -7856,7 +8064,7 @@ packages: postcss-modules-values: 4.0.0(postcss@8.4.35) postcss-value-parser: 4.2.0 semver: 7.6.0 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /css-minimizer-webpack-plugin@5.0.1(webpack@5.90.1): @@ -7890,7 +8098,7 @@ packages: postcss: 8.4.35 schema-utils: 4.2.0 serialize-javascript: 6.0.2 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /css-select@5.1.0: @@ -8046,6 +8254,79 @@ packages: stream-transform: 2.1.3 dev: false + /d3-array@3.2.1: + resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} + engines: {node: '>=12'} + dependencies: + internmap: 2.0.3 + dev: false + + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-delaunay@6.0.2: + resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} + engines: {node: '>=12'} + dependencies: + delaunator: 5.0.1 + dev: false + + /d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + dev: false + + /d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.1 + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + dev: false + + /d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.1 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + dev: false + + /d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + dependencies: + d3-path: 1.0.9 + dev: false + + /d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + dependencies: + d3-time: 3.1.0 + dev: false + + /d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.1 + dev: false + /damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: true @@ -8164,6 +8445,12 @@ packages: has-property-descriptors: 1.0.1 object-keys: 1.1.1 + /delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + dependencies: + robust-predicates: 3.0.2 + dev: false + /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -8316,6 +8603,7 @@ packages: /duplexify@4.1.2: resolution: {integrity: sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==} + requiresBuild: true dependencies: end-of-stream: 1.4.4 inherits: 2.0.4 @@ -8449,6 +8737,7 @@ packages: /ent@2.2.0: resolution: {integrity: sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==} + requiresBuild: true dev: false optional: true @@ -8596,6 +8885,7 @@ packages: '@esbuild/win32-arm64': 0.19.11 '@esbuild/win32-ia32': 0.19.11 '@esbuild/win32-x64': 0.19.11 + dev: false /escalade@3.1.2: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} @@ -9039,6 +9329,7 @@ packages: /event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + requiresBuild: true dev: false optional: true @@ -9141,6 +9432,7 @@ packages: /extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + requiresBuild: true dev: false optional: true @@ -9194,6 +9486,7 @@ packages: /fast-xml-parser@4.3.4: resolution: {integrity: sha512-utnwm92SyozgA3hhH2I8qldf2lBqm6qHOICawRNRFu1qMe3+oqr+GcXjGqTmXTMGE5T4eC03kr/rlh5C1IRdZA==} hasBin: true + requiresBuild: true dependencies: strnum: 1.0.5 dev: false @@ -9408,12 +9701,13 @@ packages: semver: 7.6.0 tapable: 2.2.1 typescript: 5.3.3 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /form-data@2.5.1: resolution: {integrity: sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==} engines: {node: '>= 0.12'} + requiresBuild: true dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -9547,6 +9841,7 @@ packages: /functional-red-black-tree@1.0.1: resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + requiresBuild: true dev: false optional: true @@ -9556,6 +9851,7 @@ packages: /gaxios@6.2.0: resolution: {integrity: sha512-H6+bHeoEAU5D6XNc6mPKeN5dLZqEDs9Gpk6I+SZBEzK5So58JVrHPmevNi35fRl1J9Y5TaeLW0kYx3pCJ1U2mQ==} engines: {node: '>=14'} + requiresBuild: true dependencies: extend: 3.0.2 https-proxy-agent: 7.0.2 @@ -9570,6 +9866,7 @@ packages: /gcp-metadata@6.1.0: resolution: {integrity: sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==} engines: {node: '>=14'} + requiresBuild: true dependencies: gaxios: 6.2.0 json-bigint: 1.0.0 @@ -9726,6 +10023,7 @@ packages: /google-auth-library@9.6.3: resolution: {integrity: sha512-4CacM29MLC2eT9Cey5GDVK4Q8t+MMp8+OEdOaqD9MG6b0dOyLORaaeJMPQ7EESVgm/+z5EKYyFLxgzBJlJgyHQ==} engines: {node: '>=14'} + requiresBuild: true dependencies: base64-js: 1.5.1 ecdsa-sig-formatter: 1.0.11 @@ -9742,6 +10040,7 @@ packages: /google-gax@4.3.0: resolution: {integrity: sha512-SWHX72gbccNfpPoeTkNmZJxmLyKWeLr0+5Ch6qtrf4oAN8KFXnyXe5EixatILnJWufM3L59MRZ4hSJWVJ3IQqw==} engines: {node: '>=14'} + requiresBuild: true dependencies: '@grpc/grpc-js': 1.9.14 '@grpc/proto-loader': 0.7.10 @@ -9779,6 +10078,7 @@ packages: /gtoken@7.1.0: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} + requiresBuild: true dependencies: gaxios: 6.2.0 jws: 4.0.0 @@ -10012,6 +10312,7 @@ packages: /https-proxy-agent@7.0.2: resolution: {integrity: sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==} engines: {node: '>= 14'} + requiresBuild: true dependencies: agent-base: 7.1.0 debug: 4.3.4 @@ -10145,6 +10446,11 @@ packages: hasown: 2.0.0 side-channel: 1.0.5 + /internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + dev: false + /interpret@1.4.0: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} @@ -11038,6 +11344,7 @@ packages: /json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + requiresBuild: true dependencies: bignumber.js: 9.1.2 dev: false @@ -11135,6 +11442,7 @@ packages: /jwa@2.0.0: resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} + requiresBuild: true dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 @@ -11165,6 +11473,7 @@ packages: /jws@4.0.0: resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + requiresBuild: true dependencies: jwa: 2.0.0 safe-buffer: 5.2.1 @@ -11225,7 +11534,7 @@ packages: dependencies: klona: 2.0.6 less: 4.1.3 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /less@4.1.3: @@ -11265,10 +11574,8 @@ packages: peerDependenciesMeta: webpack: optional: true - webpack-sources: - optional: true dependencies: - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) webpack-sources: 3.2.3 dev: true @@ -11426,7 +11733,6 @@ packages: /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - dev: true /log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} @@ -11632,6 +11938,7 @@ packages: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} hasBin: true + requiresBuild: true dev: false optional: true @@ -11656,7 +11963,7 @@ packages: webpack: ^5.0.0 dependencies: schema-utils: 4.2.0 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /minimalistic-assert@1.0.1: @@ -11885,6 +12192,7 @@ packages: /node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} + requiresBuild: true peerDependencies: encoding: ^0.1.0 peerDependenciesMeta: @@ -12561,7 +12869,7 @@ packages: klona: 2.0.6 postcss: 8.4.21 semver: 7.6.0 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /postcss-merge-longhand@6.0.2(postcss@8.4.35): @@ -12968,6 +13276,7 @@ packages: /proto3-json-serializer@2.0.1: resolution: {integrity: sha512-8awBvjO+FwkMd6gNoGFZyqkHZXCFd54CIYTb6De7dPaufGJ2XNW+QUNqbMr8MaAocMdb+KpsD4rxEOaTBDCffA==} engines: {node: '>=14.0.0'} + requiresBuild: true dependencies: protobufjs: 7.2.6 dev: false @@ -13006,6 +13315,7 @@ packages: /prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + requiresBuild: true dev: true optional: true @@ -13453,6 +13763,7 @@ packages: /retry-request@7.0.2: resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} engines: {node: '>=14'} + requiresBuild: true dependencies: '@types/request': 2.48.12 extend: 3.0.2 @@ -13481,6 +13792,10 @@ packages: dependencies: glob: 7.2.3 + /robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + dev: false + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -13541,7 +13856,7 @@ packages: klona: 2.0.6 neo-async: 2.6.2 sass: 1.70.0 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /sass@1.70.0: @@ -13560,6 +13875,7 @@ packages: /sax@1.3.0: resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==} + requiresBuild: true dev: true optional: true @@ -13911,7 +14227,7 @@ packages: abab: 2.0.6 iconv-lite: 0.6.3 source-map-js: 1.0.2 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /source-map-support@0.5.13: @@ -14027,6 +14343,7 @@ packages: /stream-events@1.0.5: resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + requiresBuild: true dependencies: stubs: 3.0.0 dev: false @@ -14034,6 +14351,7 @@ packages: /stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + requiresBuild: true dev: false optional: true @@ -14183,6 +14501,7 @@ packages: /strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} + requiresBuild: true dev: false optional: true @@ -14198,6 +14517,7 @@ packages: /stubs@3.0.0: resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + requiresBuild: true dev: false optional: true @@ -14207,7 +14527,7 @@ packages: peerDependencies: webpack: ^5.0.0 dependencies: - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /styled-components@6.1.8(react-dom@18.2.0)(react@18.2.0): @@ -14272,7 +14592,7 @@ packages: fast-glob: 3.3.2 normalize-path: 3.0.0 stylus: 0.59.0 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /stylus@0.59.0: @@ -14464,6 +14784,7 @@ packages: /teeny-request@9.0.0: resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} engines: {node: '>=14'} + requiresBuild: true dependencies: http-proxy-agent: 5.0.0 https-proxy-agent: 5.0.1 @@ -14505,6 +14826,32 @@ packages: serialize-javascript: 6.0.2 terser: 5.27.0 webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + dev: false + + /terser-webpack-plugin@5.3.10(@swc/core@1.3.85)(webpack@5.90.1): + resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + dependencies: + '@jridgewell/trace-mapping': 0.3.22 + '@swc/core': 1.3.85(@swc/helpers@0.5.2) + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.27.0 + webpack: 5.90.1(@swc/core@1.3.85) + dev: true /terser@5.27.0: resolution: {integrity: sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==} @@ -14600,6 +14947,7 @@ packages: /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + requiresBuild: true dev: false optional: true @@ -14688,7 +15036,7 @@ packages: semver: 7.6.0 source-map: 0.7.4 typescript: 5.3.3 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /ts-node@10.9.1(@swc/core@1.3.85)(@types/node@20.0.0)(typescript@5.3.3): @@ -14972,7 +15320,7 @@ packages: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /url-parse@1.5.10: @@ -15101,6 +15449,7 @@ packages: /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + requiresBuild: true dev: false optional: true @@ -15120,7 +15469,7 @@ packages: mime-types: 2.1.35 range-parser: 1.2.1 schema-utils: 4.2.0 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /webpack-dev-server@4.15.1(webpack@5.90.1): @@ -15164,7 +15513,7 @@ packages: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) webpack-dev-middleware: 5.3.3(webpack@5.90.1) ws: 8.16.0 transitivePeerDependencies: @@ -15203,7 +15552,7 @@ packages: optional: true dependencies: typed-assert: 1.0.9 - webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) + webpack: 5.90.1(@swc/core@1.3.85) dev: true /webpack@5.90.1(@swc/core@1.3.101)(esbuild@0.19.11): @@ -15244,6 +15593,47 @@ packages: - '@swc/core' - esbuild - uglify-js + dev: false + + /webpack@5.90.1(@swc/core@1.3.85): + resolution: {integrity: sha512-SstPdlAC5IvgFnhiRok8hqJo/+ArAbNv7rhU4fnWGHNVfN59HSQFaxZDSAL3IFG2YmqxuRs+IU33milSxbPlog==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.5 + '@webassemblyjs/ast': 1.11.6 + '@webassemblyjs/wasm-edit': 1.11.6 + '@webassemblyjs/wasm-parser': 1.11.6 + acorn: 8.11.3 + acorn-import-assertions: 1.9.0(acorn@8.11.3) + browserslist: 4.22.3 + chrome-trace-event: 1.0.3 + enhanced-resolve: 5.15.0 + es-module-lexer: 1.4.1 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.10(@swc/core@1.3.85)(webpack@5.90.1) + watchpack: 2.4.0 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + dev: true /websocket-driver@0.7.4: resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} @@ -15279,6 +15669,7 @@ packages: /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + requiresBuild: true dependencies: tr46: 0.0.3 webidl-conversions: 3.0.1 @@ -15521,6 +15912,7 @@ packages: /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + requiresBuild: true /yocto-queue@1.0.0: resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} From dcd7ec9c392dba5ec6dcac5496d4d3b374e88f74 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Fri, 1 Mar 2024 00:27:34 -0500 Subject: [PATCH 11/41] feat: fill in missing cycle in the bar chart --- apps/recnet/src/app/admin/stats/user-rec/page.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/recnet/src/app/admin/stats/user-rec/page.tsx b/apps/recnet/src/app/admin/stats/user-rec/page.tsx index 5a4f1e81..954b3e7a 100644 --- a/apps/recnet/src/app/admin/stats/user-rec/page.tsx +++ b/apps/recnet/src/app/admin/stats/user-rec/page.tsx @@ -71,13 +71,23 @@ const RecsBarChart = withSuspense( const date = getDateFromFirebaseTimestamp(doc.cutoff); return date.getTime(); }); - const recCountByCycle = Object.keys(recsGroupByCycle).reduce( + const recCountByCycle: Record = Object.keys( + recsGroupByCycle + ).reduce( (acc, key) => ({ ...acc, [key]: recsGroupByCycle[key].length, }), {} ); + const minTs = Math.min(...Object.keys(recCountByCycle).map(Number)); + const maxTs = Math.max(...Object.keys(recCountByCycle).map(Number)); + // fill in missing dates, increment by 1 week + for (let i = minTs; i <= maxTs; i += 604800000) { + if (!recCountByCycle[i]) { + recCountByCycle[i] = 0; + } + } return ( Date: Fri, 1 Mar 2024 00:51:10 -0500 Subject: [PATCH 12/41] Refactor RecsCycleBarChart component and update package dependencies --- .../stats/user-rec/RecsCycleBarChart.tsx | 7 +-- package.json | 2 + pnpm-lock.yaml | 62 +++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx b/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx index 766a5013..ed3a3ab9 100644 --- a/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx +++ b/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx @@ -14,13 +14,12 @@ interface RecsCycleBarChartProps { data: Record; } -const verticalMargin = 70; - function BarChart(props: RecsCycleBarChartProps) { const { parentWidth, parentHeight, data } = props; // bounds + const margin = { top: 40, right: 0, bottom: 0, left: 0 }; const xMax = parentWidth; - const yMax = parentHeight - verticalMargin; + const yMax = parentHeight - margin.top; // data const timestamps = Object.keys(data).map((ts) => parseInt(ts, 10)); @@ -44,7 +43,7 @@ function BarChart(props: RecsCycleBarChartProps) { return ( - + {Object.keys(data) .map((key) => { const ts = parseInt(key, 10); diff --git a/package.json b/package.json index db004be7..f628327f 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,12 @@ "@radix-ui/themes": "^2.0.3", "@react-email/components": "0.0.14", "@react-email/render": "^0.0.12", + "@visx/axis": "^3.8.0", "@visx/group": "^3.3.0", "@visx/responsive": "^3.3.0", "@visx/scale": "^3.5.0", "@visx/shape": "^3.5.0", + "@visx/vendor": "^3.5.0", "chance": "^1.1.11", "clsx": "^2.1.0", "firebase": "^10.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc61f0d4..03b1923f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ dependencies: '@react-email/render': specifier: ^0.0.12 version: 0.0.12 + '@visx/axis': + specifier: ^3.8.0 + version: 3.8.0(react@18.2.0) '@visx/group': specifier: ^3.3.0 version: 3.3.0(react@18.2.0) @@ -38,6 +41,9 @@ dependencies: '@visx/shape': specifier: ^3.5.0 version: 3.5.0(react@18.2.0) + '@visx/vendor': + specifier: ^3.5.0 + version: 3.5.0 chance: specifier: ^1.1.11 version: 1.1.11 @@ -6603,6 +6609,22 @@ packages: eslint-visitor-keys: 3.4.3 dev: true + /@visx/axis@3.8.0(react@18.2.0): + resolution: {integrity: sha512-CFIxPnRlIWIz8N+5n4DTSOQQ2Yb0D35YPylEkmk/c7J4haLCEhyI44JaOg6OYOk6ofCOsu9Fqe6dFAOP+MP1IQ==} + peerDependencies: + react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/react': 18.2.33 + '@visx/group': 3.3.0(react@18.2.0) + '@visx/point': 3.3.0 + '@visx/scale': 3.5.0 + '@visx/shape': 3.5.0(react@18.2.0) + '@visx/text': 3.3.0(react@18.2.0) + classnames: 2.5.1 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /@visx/curve@3.3.0: resolution: {integrity: sha512-G1l1rzGWwIs8ka3mBhO/gj8uYK6XdU/3bwRSoiZ+MockMahQFPog0bUkuVgPwwzPSJfsA/E5u53Y/DNesnHQxg==} dependencies: @@ -6621,6 +6643,10 @@ packages: react: 18.2.0 dev: false + /@visx/point@3.3.0: + resolution: {integrity: sha512-03eBBIJarkmX79WbeEGTUZwmS5/MUuabbiM9KfkGS9pETBTWkp1DZtEHZdp5z34x5TDQVLSi0rk1Plg3/8RtDg==} + dev: false + /@visx/responsive@3.3.0(react@18.2.0): resolution: {integrity: sha512-Y3Bgrh6cJ760lG6yXsxJRNCmYZAHKQqSmTG2qxJ8yImledieGEqI0ZizXJgFkxoBaZK5gSMvFsmFWKtf7a86kQ==} peerDependencies: @@ -6659,6 +6685,20 @@ packages: react: 18.2.0 dev: false + /@visx/text@3.3.0(react@18.2.0): + resolution: {integrity: sha512-fOimcsf0GtQE9whM5MdA/xIkHMaV29z7qNqNXysUDE8znSMKsN+ott7kSg2ljAEE89CQo3WKHkPNettoVsa84w==} + peerDependencies: + react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/lodash': 4.14.202 + '@types/react': 18.2.33 + classnames: 2.5.1 + lodash: 4.17.21 + prop-types: 15.8.1 + react: 18.2.0 + reduce-css-calc: 1.3.0 + dev: false + /@visx/vendor@3.5.0: resolution: {integrity: sha512-yt3SEZRVmt36+APsCISSO9eSOtzQkBjt+QRxNRzcTWuzwMAaF3PHCCSe31++kkpgY9yFoF+Gfes1TBe5NlETiQ==} dependencies: @@ -7391,6 +7431,10 @@ packages: babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.9) dev: true + /balanced-match@0.4.2: + resolution: {integrity: sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg==} + dev: false + /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -11841,6 +11885,10 @@ packages: engines: {node: '>=8'} dev: false + /math-expression-evaluator@1.4.0: + resolution: {integrity: sha512-4vRUvPyxdO8cWULGTh9dZWL2tZK6LDBvj+OGHBER7poH9Qdt7kXEoj20wiz4lQUbUXQZFjPbe5mVDo9nutizCw==} + dev: false + /mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} dev: true @@ -13622,6 +13670,20 @@ packages: strip-indent: 3.0.0 dev: false + /reduce-css-calc@1.3.0: + resolution: {integrity: sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA==} + dependencies: + balanced-match: 0.4.2 + math-expression-evaluator: 1.4.0 + reduce-function-call: 1.0.3 + dev: false + + /reduce-function-call@1.0.3: + resolution: {integrity: sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==} + dependencies: + balanced-match: 1.0.2 + dev: false + /reflect.getprototypeof@1.0.5: resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==} engines: {node: '>= 0.4'} From fc08cffc825ad141424f9ad9c61e807b1c51763c Mon Sep 17 00:00:00 2001 From: swh00tw Date: Mon, 4 Mar 2024 14:21:57 -0500 Subject: [PATCH 13/41] feat(admin panel): add AxisBottom component to RecsCycleBarChart --- .../stats/user-rec/RecsCycleBarChart.tsx | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx b/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx index ed3a3ab9..7685af52 100644 --- a/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx +++ b/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx @@ -5,6 +5,7 @@ import { scaleUtc, scaleLinear } from "@visx/scale"; import { ParentSize } from "@visx/responsive"; import { Bar } from "@visx/shape"; import { Group } from "@visx/group"; +import { AxisBottom } from "@visx/axis"; type Timestamp = string; @@ -14,21 +15,23 @@ interface RecsCycleBarChartProps { data: Record; } +const themeColor = "#2A78D0"; + function BarChart(props: RecsCycleBarChartProps) { const { parentWidth, parentHeight, data } = props; // bounds - const margin = { top: 40, right: 0, bottom: 0, left: 0 }; + const margin = { top: 40, right: 0, bottom: 40, left: 0 }; const xMax = parentWidth; - const yMax = parentHeight - margin.top; + const yMax = parentHeight - margin.top - margin.bottom; // data - const timestamps = Object.keys(data).map((ts) => parseInt(ts, 10)); + const timestamps = Object.keys(data).map((key) => parseInt(key, 10)); const xScale = useMemo(() => { return scaleUtc({ domain: [ - new Date(Math.min(...timestamps)), - new Date(Math.max(...timestamps)), + Math.min(...timestamps), + Math.max(...timestamps) + 604800000, // add 1 week ], range: [0, xMax], }); @@ -70,6 +73,23 @@ function BarChart(props: RecsCycleBarChartProps) { ); })} + { + if (d instanceof Date) { + return xScale.tickFormat(undefined, "%b %d")(d); + } + return ""; + }} + stroke={themeColor} + tickStroke={themeColor} + tickLabelProps={{ + fill: themeColor, + fontSize: 9, + textAnchor: "middle", + }} + /> ); } From eb25320fd36fc9f66e4008bf2e97124093839630 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Mon, 4 Mar 2024 15:20:27 -0500 Subject: [PATCH 14/41] feat: finish admin panel user & rec page --- apps/recnet/src/app/admin/stats/StatBox.tsx | 3 +- .../stats/user-rec/RecsCycleBarChart.tsx | 163 ++++++++++---- .../src/app/admin/stats/user-rec/page.tsx | 15 +- apps/recnet/src/utils/date.ts | 1 + package.json | 2 + pnpm-lock.yaml | 209 +++++++----------- 6 files changed, 210 insertions(+), 183 deletions(-) diff --git a/apps/recnet/src/app/admin/stats/StatBox.tsx b/apps/recnet/src/app/admin/stats/StatBox.tsx index 24d4dc78..c3a76a5d 100644 --- a/apps/recnet/src/app/admin/stats/StatBox.tsx +++ b/apps/recnet/src/app/admin/stats/StatBox.tsx @@ -23,12 +23,13 @@ export function StatBox({ "p-6", "gap-y-3", "w-fit", + "relative", className )} >
{icon} diff --git a/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx b/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx index 7685af52..fd490c4e 100644 --- a/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx +++ b/apps/recnet/src/app/admin/stats/user-rec/RecsCycleBarChart.tsx @@ -6,6 +6,10 @@ import { ParentSize } from "@visx/responsive"; import { Bar } from "@visx/shape"; import { Group } from "@visx/group"; import { AxisBottom } from "@visx/axis"; +import { useTooltip, useTooltipInPortal, defaultStyles } from "@visx/tooltip"; +import { localPoint } from "@visx/event"; +import { WeekTs, formatDate } from "@/utils/date"; +import { Text } from "@radix-ui/themes"; type Timestamp = string; @@ -15,17 +19,45 @@ interface RecsCycleBarChartProps { data: Record; } +interface TooltipData { + ts: number; + count: number; +} + const themeColor = "#2A78D0"; +const tooltipStyles = { + ...defaultStyles, + minWidth: 60, + backgroundColor: "black", + color: "white", +}; +let tooltipTimeout: number; function BarChart(props: RecsCycleBarChartProps) { const { parentWidth, parentHeight, data } = props; + const { + tooltipOpen, + tooltipLeft, + tooltipTop, + tooltipData, + hideTooltip, + showTooltip, + } = useTooltip(); + + const { containerRef, TooltipInPortal } = useTooltipInPortal({ + // TooltipInPortal is rendered in a separate child of and positioned + // with page coordinates which should be updated on scroll. consider using + // Tooltip or TooltipWithBounds if you don't need to render inside a Portal + scroll: true, + }); + // data + const timestamps = Object.keys(data).map((key) => parseInt(key, 10)); + // bounds const margin = { top: 40, right: 0, bottom: 40, left: 0 }; const xMax = parentWidth; const yMax = parentHeight - margin.top - margin.bottom; - - // data - const timestamps = Object.keys(data).map((key) => parseInt(key, 10)); + const barWidth = parentWidth / timestamps.length - 5; const xScale = useMemo(() => { return scaleUtc({ @@ -45,52 +77,85 @@ function BarChart(props: RecsCycleBarChartProps) { }, [yMax, data]); return ( - - - {Object.keys(data) - .map((key) => { - const ts = parseInt(key, 10); - return { - ts, - count: data[key], - }; - }) - .map((d) => { - const barWidth = 20; - const barHeight = yMax - (yScale(d.count) ?? 0); - const barX = xScale(new Date(d.ts)); - const barY = yMax - barHeight; - return ( - {}} - /> - ); - })} - - { - if (d instanceof Date) { - return xScale.tickFormat(undefined, "%b %d")(d); - } - return ""; - }} - stroke={themeColor} - tickStroke={themeColor} - tickLabelProps={{ - fill: themeColor, - fontSize: 9, - textAnchor: "middle", - }} - /> - + <> + + + {Object.keys(data) + .map((key) => { + const ts = parseInt(key, 10); + return { + ts, + count: data[key], + }; + }) + .map((d) => { + const barHeight = yMax - (yScale(d.count) ?? 0); + const barX = xScale(new Date(d.ts)); + const barY = yMax - barHeight; + return ( + {}} + onMouseLeave={() => { + tooltipTimeout = window.setTimeout(() => { + hideTooltip(); + }, 300); + }} + onMouseMove={(event) => { + if (tooltipTimeout) clearTimeout(tooltipTimeout); + // TooltipInPortal expects coordinates to be relative to containerRef + // localPoint returns coordinates relative to the nearest SVG, which + // is what containerRef is set to. + const eventSvgCoords = localPoint(event); + const left = barX + barWidth / 2; + showTooltip({ + tooltipData: { + ts: d.ts, + count: d.count, + }, + tooltipTop: eventSvgCoords?.y, + tooltipLeft: left, + }); + }} + /> + ); + })} + + { + if (d instanceof Date) { + return xScale.tickFormat(undefined, "%b %d")(d); + } + return ""; + }} + stroke={themeColor} + tickStroke={themeColor} + tickLabelProps={{ + fill: themeColor, + fontSize: 9, + textAnchor: "middle", + }} + /> + + {tooltipData && tooltipOpen ? ( + + {`${formatDate(new Date(tooltipData.ts - WeekTs))} ~ ${formatDate(new Date(tooltipData.ts))}`} +
Num of Rec: {tooltipData.count}
+
+ ) : null} + ); } diff --git a/apps/recnet/src/app/admin/stats/user-rec/page.tsx b/apps/recnet/src/app/admin/stats/user-rec/page.tsx index 954b3e7a..81c3379d 100644 --- a/apps/recnet/src/app/admin/stats/user-rec/page.tsx +++ b/apps/recnet/src/app/admin/stats/user-rec/page.tsx @@ -1,7 +1,7 @@ import { db } from "@/firebase/admin"; import { StatBox, StatBoxSkeleton } from "@/app/admin/stats/StatBox"; import { Pencil1Icon, PersonIcon } from "@radix-ui/react-icons"; -import { getDateFromFirebaseTimestamp, getLatestCutOff } from "@/utils/date"; +import { getDateFromFirebaseTimestamp, getNextCutOff } from "@/utils/date"; import { Timestamp } from "firebase-admin/firestore"; import { withSuspense } from "@/utils/withSuspense"; import groupBy from "lodash.groupby"; @@ -37,7 +37,8 @@ const RecCount = withSuspense( const RecsThisCycle = withSuspense( async () => { - const cutOff = getLatestCutOff(); + const cutOff = getNextCutOff(); + console.log("cutOff", cutOff); const recsThisCycle = await db .collection("recommendations") .where("cutoff", "==", Timestamp.fromMillis(cutOff.getTime())) @@ -62,7 +63,7 @@ const RecsBarChart = withSuspense( if (res.success) { return res.data; } else { - console.error("Failed to parse rec", res.error); + // console.error("Failed to parse rec", res.error); return null; } }) @@ -93,9 +94,11 @@ const RecsBarChart = withSuspense( } - className="h-[300px] w-[40%] min-w-[500px]" + className="h-[300px] w-[100%] md:w-[40%] overflow-x-auto whitespace-nowrap overflow-y-hidden" > - +
+ +
); }, @@ -105,7 +108,7 @@ const RecsBarChart = withSuspense( export default async function UserRecStats() { return (
-
+
diff --git a/apps/recnet/src/utils/date.ts b/apps/recnet/src/utils/date.ts index 82df7331..e3c55120 100644 --- a/apps/recnet/src/utils/date.ts +++ b/apps/recnet/src/utils/date.ts @@ -2,6 +2,7 @@ import { FirebaseTs } from "@/types/rec"; import { Timestamp } from "firebase/firestore"; const CYCLE_DUE_DAY = 2; +export const WeekTs = 604800000 as const; export const START_DATE = new Date(2023, 9, 24); export const Months = [ "Jan", diff --git a/package.json b/package.json index f628327f..c0ee02f1 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,12 @@ "@react-email/components": "0.0.14", "@react-email/render": "^0.0.12", "@visx/axis": "^3.8.0", + "@visx/event": "^3.3.0", "@visx/group": "^3.3.0", "@visx/responsive": "^3.3.0", "@visx/scale": "^3.5.0", "@visx/shape": "^3.5.0", + "@visx/tooltip": "^3.3.0", "@visx/vendor": "^3.5.0", "chance": "^1.1.11", "clsx": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03b1923f..4282e97c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - dependencies: '@hookform/resolvers': specifier: ^3.3.4 @@ -29,6 +25,9 @@ dependencies: '@visx/axis': specifier: ^3.8.0 version: 3.8.0(react@18.2.0) + '@visx/event': + specifier: ^3.3.0 + version: 3.3.0 '@visx/group': specifier: ^3.3.0 version: 3.3.0(react@18.2.0) @@ -41,6 +40,9 @@ dependencies: '@visx/shape': specifier: ^3.5.0 version: 3.5.0(react@18.2.0) + '@visx/tooltip': + specifier: ^3.3.0 + version: 3.3.0(react-dom@18.2.0)(react@18.2.0) '@visx/vendor': specifier: ^3.5.0 version: 3.5.0 @@ -1796,7 +1798,6 @@ packages: cpu: [ppc64] os: [aix] requiresBuild: true - dev: false optional: true /@esbuild/android-arm64@0.19.11: @@ -1805,7 +1806,6 @@ packages: cpu: [arm64] os: [android] requiresBuild: true - dev: false optional: true /@esbuild/android-arm@0.19.11: @@ -1814,7 +1814,6 @@ packages: cpu: [arm] os: [android] requiresBuild: true - dev: false optional: true /@esbuild/android-x64@0.19.11: @@ -1823,7 +1822,6 @@ packages: cpu: [x64] os: [android] requiresBuild: true - dev: false optional: true /@esbuild/darwin-arm64@0.19.11: @@ -1832,7 +1830,6 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true - dev: false optional: true /@esbuild/darwin-x64@0.19.11: @@ -1841,7 +1838,6 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true - dev: false optional: true /@esbuild/freebsd-arm64@0.19.11: @@ -1850,7 +1846,6 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true - dev: false optional: true /@esbuild/freebsd-x64@0.19.11: @@ -1859,7 +1854,6 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true - dev: false optional: true /@esbuild/linux-arm64@0.19.11: @@ -1868,7 +1862,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-arm@0.19.11: @@ -1877,7 +1870,6 @@ packages: cpu: [arm] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-ia32@0.19.11: @@ -1886,7 +1878,6 @@ packages: cpu: [ia32] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-loong64@0.19.11: @@ -1895,7 +1886,6 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-mips64el@0.19.11: @@ -1904,7 +1894,6 @@ packages: cpu: [mips64el] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-ppc64@0.19.11: @@ -1913,7 +1902,6 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-riscv64@0.19.11: @@ -1922,7 +1910,6 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-s390x@0.19.11: @@ -1931,7 +1918,6 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/linux-x64@0.19.11: @@ -1940,7 +1926,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: false optional: true /@esbuild/netbsd-x64@0.19.11: @@ -1949,7 +1934,6 @@ packages: cpu: [x64] os: [netbsd] requiresBuild: true - dev: false optional: true /@esbuild/openbsd-x64@0.19.11: @@ -1958,7 +1942,6 @@ packages: cpu: [x64] os: [openbsd] requiresBuild: true - dev: false optional: true /@esbuild/sunos-x64@0.19.11: @@ -1967,7 +1950,6 @@ packages: cpu: [x64] os: [sunos] requiresBuild: true - dev: false optional: true /@esbuild/win32-arm64@0.19.11: @@ -1976,7 +1958,6 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true - dev: false optional: true /@esbuild/win32-ia32@0.19.11: @@ -1985,7 +1966,6 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true - dev: false optional: true /@esbuild/win32-x64@0.19.11: @@ -1994,7 +1974,6 @@ packages: cpu: [x64] os: [win32] requiresBuild: true - dev: false optional: true /@eslint-community/eslint-utils@4.4.0(eslint@8.48.0): @@ -3812,11 +3791,11 @@ packages: style-loader: 3.3.4(webpack@5.90.1) stylus: 0.59.0 stylus-loader: 7.1.3(stylus@0.59.0)(webpack@5.90.1) - terser-webpack-plugin: 5.3.10(@swc/core@1.3.85)(webpack@5.90.1) + terser-webpack-plugin: 5.3.10(@swc/core@1.3.101)(esbuild@0.19.11)(webpack@5.90.1) ts-loader: 9.5.1(typescript@5.3.3)(webpack@5.90.1) tsconfig-paths-webpack-plugin: 4.0.0 tslib: 2.6.2 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) webpack-dev-server: 4.15.1(webpack@5.90.1) webpack-node-externals: 3.0.0 webpack-subresource-integrity: 5.1.0(webpack@5.90.1) @@ -5721,7 +5700,6 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true - dev: false optional: true /@swc/core-darwin-arm64@1.3.85: @@ -5738,7 +5716,6 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true - dev: false optional: true /@swc/core-darwin-x64@1.3.85: @@ -5755,7 +5732,6 @@ packages: cpu: [arm] os: [linux] requiresBuild: true - dev: false optional: true /@swc/core-linux-arm-gnueabihf@1.3.85: @@ -5772,7 +5748,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: false optional: true /@swc/core-linux-arm64-gnu@1.3.85: @@ -5789,7 +5764,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: false optional: true /@swc/core-linux-arm64-musl@1.3.85: @@ -5806,7 +5780,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: false optional: true /@swc/core-linux-x64-gnu@1.3.85: @@ -5823,7 +5796,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: false optional: true /@swc/core-linux-x64-musl@1.3.85: @@ -5840,7 +5812,6 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true - dev: false optional: true /@swc/core-win32-arm64-msvc@1.3.85: @@ -5857,7 +5828,6 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true - dev: false optional: true /@swc/core-win32-ia32-msvc@1.3.85: @@ -5874,7 +5844,6 @@ packages: cpu: [x64] os: [win32] requiresBuild: true - dev: false optional: true /@swc/core-win32-x64-msvc@1.3.85: @@ -5909,7 +5878,6 @@ packages: '@swc/core-win32-arm64-msvc': 1.3.101 '@swc/core-win32-ia32-msvc': 1.3.101 '@swc/core-win32-x64-msvc': 1.3.101 - dev: false /@swc/core@1.3.85(@swc/helpers@0.5.2): resolution: {integrity: sha512-qnoxp+2O0GtvRdYnXgR1v8J7iymGGYpx6f6yCK9KxipOZOjrlKILFANYlghQxZyPUfXwK++TFxfSlX4r9wK+kg==} @@ -5937,7 +5905,6 @@ packages: /@swc/counter@0.1.3: resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - dev: false /@swc/helpers@0.5.2: resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} @@ -6625,6 +6592,19 @@ packages: react: 18.2.0 dev: false + /@visx/bounds@3.3.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-gESmN+4N2NkeUzqQEDZaS63umkGfMp9XjQcKBqtOR64mjjQtamh3lNVRWvKjJ2Zb421RbYHWq22Wv9nay6ZUOg==} + peerDependencies: + react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + react-dom: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/react': 18.2.33 + '@types/react-dom': 18.2.14 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@visx/curve@3.3.0: resolution: {integrity: sha512-G1l1rzGWwIs8ka3mBhO/gj8uYK6XdU/3bwRSoiZ+MockMahQFPog0bUkuVgPwwzPSJfsA/E5u53Y/DNesnHQxg==} dependencies: @@ -6632,6 +6612,13 @@ packages: d3-shape: 1.3.7 dev: false + /@visx/event@3.3.0: + resolution: {integrity: sha512-fKalbNgNz2ooVOTXhvcOx5IlEQDgVfX66rI7bgZhBxI2/scy+5rWcXJXpwkheRF68SMx9R93SjKW6tmiD0h+jA==} + dependencies: + '@types/react': 18.2.33 + '@visx/point': 3.3.0 + dev: false + /@visx/group@3.3.0(react@18.2.0): resolution: {integrity: sha512-yKepDKwJqlzvnvPS0yDuW13XNrYJE4xzT6xM7J++441nu6IybWWwextyap8ey+kU651cYDb+q1Oi6aHvQwyEyw==} peerDependencies: @@ -6699,6 +6686,21 @@ packages: reduce-css-calc: 1.3.0 dev: false + /@visx/tooltip@3.3.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-0ovbxnvAphEU/RVJprWHdOJT7p3YfBDpwXclXRuhIY2EkH59g8sDHatDcYwiNPeqk61jBh1KACRZxqToMuutlg==} + peerDependencies: + react: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 + react-dom: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/react': 18.2.33 + '@visx/bounds': 3.3.0(react-dom@18.2.0)(react@18.2.0) + classnames: 2.5.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-use-measure: 2.1.1(react-dom@18.2.0)(react@18.2.0) + dev: false + /@visx/vendor@3.5.0: resolution: {integrity: sha512-yt3SEZRVmt36+APsCISSO9eSOtzQkBjt+QRxNRzcTWuzwMAaF3PHCCSe31++kkpgY9yFoF+Gfes1TBe5NlETiQ==} dependencies: @@ -7304,7 +7306,7 @@ packages: '@babel/core': 7.23.9 find-cache-dir: 4.0.0 schema-utils: 4.2.0 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /babel-plugin-const-enum@1.2.0(@babel/core@7.23.9): @@ -7972,7 +7974,7 @@ packages: normalize-path: 3.0.0 schema-utils: 4.2.0 serialize-javascript: 6.0.2 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /core-js-compat@3.35.1: @@ -8108,7 +8110,7 @@ packages: postcss-modules-values: 4.0.0(postcss@8.4.35) postcss-value-parser: 4.2.0 semver: 7.6.0 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /css-minimizer-webpack-plugin@5.0.1(webpack@5.90.1): @@ -8142,7 +8144,7 @@ packages: postcss: 8.4.35 schema-utils: 4.2.0 serialize-javascript: 6.0.2 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /css-select@5.1.0: @@ -8384,6 +8386,10 @@ packages: whatwg-url: 11.0.0 dev: true + /debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + dev: false + /debounce@2.0.0: resolution: {integrity: sha512-xRetU6gL1VJbs85Mc4FoEGSjQxzpdxRyFhe3lmWFyy2EzydIcD4xzUvRJMD+NPDfMwKNhxa3PvsIOU32luIWeA==} engines: {node: '>=18'} @@ -8929,7 +8935,6 @@ packages: '@esbuild/win32-arm64': 0.19.11 '@esbuild/win32-ia32': 0.19.11 '@esbuild/win32-x64': 0.19.11 - dev: false /escalade@3.1.2: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} @@ -9745,7 +9750,7 @@ packages: semver: 7.6.0 tapable: 2.2.1 typescript: 5.3.3 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /form-data@2.5.1: @@ -11578,7 +11583,7 @@ packages: dependencies: klona: 2.0.6 less: 4.1.3 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /less@4.1.3: @@ -11618,8 +11623,10 @@ packages: peerDependenciesMeta: webpack: optional: true + webpack-sources: + optional: true dependencies: - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) webpack-sources: 3.2.3 dev: true @@ -12011,7 +12018,7 @@ packages: webpack: ^5.0.0 dependencies: schema-utils: 4.2.0 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /minimalistic-assert@1.0.1: @@ -12917,7 +12924,7 @@ packages: klona: 2.0.6 postcss: 8.4.21 semver: 7.6.0 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /postcss-merge-longhand@6.0.2(postcss@8.4.35): @@ -13589,6 +13596,17 @@ packages: tslib: 2.6.2 dev: false + /react-use-measure@2.1.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-nocZhN26cproIiIduswYpV5y5lQpSQS1y/4KuvUCjSKmw7ZWIS/+g3aFnX3WdBkyuGUtTLif3UTqnLLhbDoQig==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + dependencies: + debounce: 1.2.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -13918,7 +13936,7 @@ packages: klona: 2.0.6 neo-async: 2.6.2 sass: 1.70.0 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /sass@1.70.0: @@ -14289,7 +14307,7 @@ packages: abab: 2.0.6 iconv-lite: 0.6.3 source-map-js: 1.0.2 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /source-map-support@0.5.13: @@ -14589,7 +14607,7 @@ packages: peerDependencies: webpack: ^5.0.0 dependencies: - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /styled-components@6.1.8(react-dom@18.2.0)(react@18.2.0): @@ -14654,7 +14672,7 @@ packages: fast-glob: 3.3.2 normalize-path: 3.0.0 stylus: 0.59.0 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /stylus@0.59.0: @@ -14888,32 +14906,6 @@ packages: serialize-javascript: 6.0.2 terser: 5.27.0 webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) - dev: false - - /terser-webpack-plugin@5.3.10(@swc/core@1.3.85)(webpack@5.90.1): - resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@swc/core': '*' - esbuild: '*' - uglify-js: '*' - webpack: ^5.1.0 - peerDependenciesMeta: - '@swc/core': - optional: true - esbuild: - optional: true - uglify-js: - optional: true - dependencies: - '@jridgewell/trace-mapping': 0.3.22 - '@swc/core': 1.3.85(@swc/helpers@0.5.2) - jest-worker: 27.5.1 - schema-utils: 3.3.0 - serialize-javascript: 6.0.2 - terser: 5.27.0 - webpack: 5.90.1(@swc/core@1.3.85) - dev: true /terser@5.27.0: resolution: {integrity: sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==} @@ -15098,7 +15090,7 @@ packages: semver: 7.6.0 source-map: 0.7.4 typescript: 5.3.3 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /ts-node@10.9.1(@swc/core@1.3.85)(@types/node@20.0.0)(typescript@5.3.3): @@ -15382,7 +15374,7 @@ packages: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /url-parse@1.5.10: @@ -15531,7 +15523,7 @@ packages: mime-types: 2.1.35 range-parser: 1.2.1 schema-utils: 4.2.0 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /webpack-dev-server@4.15.1(webpack@5.90.1): @@ -15575,7 +15567,7 @@ packages: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) webpack-dev-middleware: 5.3.3(webpack@5.90.1) ws: 8.16.0 transitivePeerDependencies: @@ -15614,7 +15606,7 @@ packages: optional: true dependencies: typed-assert: 1.0.9 - webpack: 5.90.1(@swc/core@1.3.85) + webpack: 5.90.1(@swc/core@1.3.101)(esbuild@0.19.11) dev: true /webpack@5.90.1(@swc/core@1.3.101)(esbuild@0.19.11): @@ -15655,47 +15647,6 @@ packages: - '@swc/core' - esbuild - uglify-js - dev: false - - /webpack@5.90.1(@swc/core@1.3.85): - resolution: {integrity: sha512-SstPdlAC5IvgFnhiRok8hqJo/+ArAbNv7rhU4fnWGHNVfN59HSQFaxZDSAL3IFG2YmqxuRs+IU33milSxbPlog==} - engines: {node: '>=10.13.0'} - hasBin: true - peerDependencies: - webpack-cli: '*' - peerDependenciesMeta: - webpack-cli: - optional: true - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.5 - '@webassemblyjs/ast': 1.11.6 - '@webassemblyjs/wasm-edit': 1.11.6 - '@webassemblyjs/wasm-parser': 1.11.6 - acorn: 8.11.3 - acorn-import-assertions: 1.9.0(acorn@8.11.3) - browserslist: 4.22.3 - chrome-trace-event: 1.0.3 - enhanced-resolve: 5.15.0 - es-module-lexer: 1.4.1 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.3.85)(webpack@5.90.1) - watchpack: 2.4.0 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - dev: true /websocket-driver@0.7.4: resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} @@ -15988,3 +15939,7 @@ packages: /zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false From 87a6220f5ab48073ab750c5ffc10b941fa3700d6 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Mon, 4 Mar 2024 15:22:37 -0500 Subject: [PATCH 15/41] Redirect to user-rec stats page in admin page --- apps/recnet/src/app/admin/page.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/recnet/src/app/admin/page.tsx b/apps/recnet/src/app/admin/page.tsx index d63ad8d7..ae84e69e 100644 --- a/apps/recnet/src/app/admin/page.tsx +++ b/apps/recnet/src/app/admin/page.tsx @@ -1,7 +1,14 @@ import { cn } from "@/utils/cn"; +import { redirect } from "next/navigation"; +// Leave this page here, redirect to user-rec stats for now +// We would want to build admin main page in the future export default function AdminPage() { + redirect("/admin/stats/user-rec"); + return ( -
Admin Page
+
+ To be implemented +
); } From 249d38fa745d7af002a810030b5e134b8e48c9d0 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Mon, 4 Mar 2024 15:58:51 -0500 Subject: [PATCH 16/41] Add Tooltip component to AdminPanelNav --- apps/recnet/src/app/admin/AdminPanelNav.tsx | 33 ++++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/recnet/src/app/admin/AdminPanelNav.tsx b/apps/recnet/src/app/admin/AdminPanelNav.tsx index 1c12121f..ab139371 100644 --- a/apps/recnet/src/app/admin/AdminPanelNav.tsx +++ b/apps/recnet/src/app/admin/AdminPanelNav.tsx @@ -1,7 +1,7 @@ "use client"; import { cn } from "@/utils/cn"; -import { Text } from "@radix-ui/themes"; +import { Text, Tooltip } from "@radix-ui/themes"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { createContext, useContext } from "react"; @@ -52,26 +52,38 @@ function AdminPanelNav({ children }: { children: React.ReactNode }) { ); } -function NavItem(props: { route: string; label: string }) { +function NavItem(props: { route: string; label: string; wip?: boolean }) { useAdminPanelNavContext(); - const { route, label } = props; + const { route, label, wip = false } = props; const pathname = usePathname(); const isActive = pathname === `/admin/${route}`; + const ItemWrapper = ({ children }: { children: React.ReactNode }) => { + if (wip) { + return ( + + {children} + + ); + } + return {children}; + }; + return ( - +
- {label} + {`${wip ? "🚧 " : ""}${label}`}
- +
); } AdminPanelNav.Item = NavItem; @@ -103,13 +115,18 @@ export function AdminPanelNavbar() { - + - + From 2008dafe6dea72464abd01b6e11befe346506593 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 10:33:54 -0500 Subject: [PATCH 17/41] feat: add invite code monitor page --- apps/recnet/src/app/admin/AdminPanelNav.tsx | 2 +- apps/recnet/src/app/admin/invite-code/monitor/page.tsx | 7 +++++++ apps/recnet/src/app/admin/layout.tsx | 2 +- apps/recnet/src/app/admin/stats/user-rec/page.tsx | 4 ++-- apps/recnet/tsconfig.json | 1 + 5 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 apps/recnet/src/app/admin/invite-code/monitor/page.tsx diff --git a/apps/recnet/src/app/admin/AdminPanelNav.tsx b/apps/recnet/src/app/admin/AdminPanelNav.tsx index ab139371..d6be2a67 100644 --- a/apps/recnet/src/app/admin/AdminPanelNav.tsx +++ b/apps/recnet/src/app/admin/AdminPanelNav.tsx @@ -122,7 +122,7 @@ export function AdminPanelNavbar() { /> - + +

Invite Code Monitor

+
+ ); +} diff --git a/apps/recnet/src/app/admin/layout.tsx b/apps/recnet/src/app/admin/layout.tsx index 30e782ef..bc2c89f8 100644 --- a/apps/recnet/src/app/admin/layout.tsx +++ b/apps/recnet/src/app/admin/layout.tsx @@ -27,7 +27,7 @@ export default async function Layout({ )} > -
{children}
+
{children}
); } diff --git a/apps/recnet/src/app/admin/stats/user-rec/page.tsx b/apps/recnet/src/app/admin/stats/user-rec/page.tsx index 81c3379d..70a01a40 100644 --- a/apps/recnet/src/app/admin/stats/user-rec/page.tsx +++ b/apps/recnet/src/app/admin/stats/user-rec/page.tsx @@ -94,7 +94,7 @@ const RecsBarChart = withSuspense( } - className="h-[300px] w-[100%] md:w-[40%] overflow-x-auto whitespace-nowrap overflow-y-hidden" + className="h-[300px] w-[100%] md:w-[55%] overflow-x-auto whitespace-nowrap overflow-y-hidden" >
@@ -107,7 +107,7 @@ const RecsBarChart = withSuspense( export default async function UserRecStats() { return ( -
+
diff --git a/apps/recnet/tsconfig.json b/apps/recnet/tsconfig.json index b2deb806..412fb4a7 100644 --- a/apps/recnet/tsconfig.json +++ b/apps/recnet/tsconfig.json @@ -11,6 +11,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, + "importHelpers": true, "jsx": "preserve", "incremental": true, "plugins": [ From 93856747286921eb34f47a00990c48cb9fdcd37d Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 11:39:17 -0500 Subject: [PATCH 18/41] feat: add invite codes hookk --- apps/recnet/src/app/admin/AdminSections.tsx | 40 ++++++++++++ .../app/admin/invite-code/monitor/page.tsx | 18 +++++- apps/recnet/src/app/api/inviteCodes/route.ts | 63 +++++++++++++++++++ apps/recnet/src/hooks/useInviteCodes.ts | 50 +++++++++++++++ apps/recnet/src/types/inviteCode.ts | 13 ++++ 5 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 apps/recnet/src/app/admin/AdminSections.tsx create mode 100644 apps/recnet/src/app/api/inviteCodes/route.ts create mode 100644 apps/recnet/src/hooks/useInviteCodes.ts create mode 100644 apps/recnet/src/types/inviteCode.ts diff --git a/apps/recnet/src/app/admin/AdminSections.tsx b/apps/recnet/src/app/admin/AdminSections.tsx new file mode 100644 index 00000000..21dcdb59 --- /dev/null +++ b/apps/recnet/src/app/admin/AdminSections.tsx @@ -0,0 +1,40 @@ +import { Text } from "@radix-ui/themes"; +import { cn } from "@/utils/cn"; + +export function AdminSectionTitle(props: { + children: React.ReactNode; + description?: string; +}) { + const { children, description } = props; + return ( +
+ + {children} + + {description ? ( + + {description} + + ) : null} +
+ ); +} + +export function AdminSectionBox(props: { children: React.ReactNode }) { + const { children } = props; + + return ( +
+ {children} +
+ ); +} diff --git a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx index 8e001fb9..3cf828bd 100644 --- a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx +++ b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx @@ -1,7 +1,21 @@ +"use client"; + +import { cn } from "@/utils/cn"; +import { AdminSectionBox, AdminSectionTitle } from "@/app/admin/AdminSections"; +import { useInviteCodes } from "@/hooks/useInviteCodes"; + export default function InviteCodeMonitorPage() { + const { inviteCodes } = useInviteCodes(); + console.log("inviteCodes", inviteCodes); + return ( -
-

Invite Code Monitor

+
+
+ + Invite Code Monitor + + 123 +
); } diff --git a/apps/recnet/src/app/api/inviteCodes/route.ts b/apps/recnet/src/app/api/inviteCodes/route.ts new file mode 100644 index 00000000..1237de0f --- /dev/null +++ b/apps/recnet/src/app/api/inviteCodes/route.ts @@ -0,0 +1,63 @@ +import { db } from "@/firebase/admin"; +import { inviteCodeSchema } from "@/types/inviteCode"; +import { UserSchema } from "@/types/user"; +import { notEmpty } from "@/utils/notEmpty"; + +export async function GET() { + try { + // get all invite codes + const inviteCodes = await db.collection("invite-codes").get(); + // gather all userIds + const userIds = [ + ...inviteCodes.docs + .map((inviteCode) => inviteCode.data().usedBy) + .filter(notEmpty), + ...inviteCodes.docs + .map((inviteCode) => inviteCode.data().issuedTo) + .filter(notEmpty), + ]; + // get all users data + const users = await Promise.all( + userIds.map(async (userId) => { + const user = await db.collection("users").doc(userId).get(); + // parse user data + const res = UserSchema.safeParse({ + ...user.data(), + id: user.id, + }); + if (res.success) { + return res.data; + } + return null; + }) + ); + + // join users to invite codes + const inviteCodesWithUsers = inviteCodes.docs + .map((inviteCode) => { + const inviteCodeData = inviteCode.data(); + const user = users.find((user) => user?.id === inviteCodeData.usedBy); + const owner = users.find( + (user) => user?.id === inviteCodeData.issuedTo + ); + const res = inviteCodeSchema.safeParse({ + ...inviteCodeData, + issuedTo: owner, + usedBy: user, + }); + if (res.success) { + return res.data; + } + return null; + }) + .filter(notEmpty); + + return Response.json({ + inviteCodes: inviteCodesWithUsers, + }); + } catch (error) { + return Response.json(null, { + status: 500, + }); + } +} diff --git a/apps/recnet/src/hooks/useInviteCodes.ts b/apps/recnet/src/hooks/useInviteCodes.ts new file mode 100644 index 00000000..10bebd94 --- /dev/null +++ b/apps/recnet/src/hooks/useInviteCodes.ts @@ -0,0 +1,50 @@ +import useSWR from "swr"; +import { fetchWithZod } from "@/utils/zodFetch"; +import { inviteCodeSchema } from "@/types/inviteCode"; +import { toast } from "sonner"; +import { z } from "zod"; + +const InviteCodesSchema = z.object({ + inviteCodes: z.array(inviteCodeSchema), +}); + +type InviteCodes = z.infer; + +export function useInviteCodes(options?: { + readonly onSuccessCallback?: ( + data: InviteCodes, + key: string, + config: unknown + ) => void; + readonly onErrorCallback?: ( + error: unknown, + key: string, + config: unknown + ) => void; +}) { + const onSuccessCallback = options?.onSuccessCallback; + const onErrorCallback = options?.onErrorCallback; + const { data, error, mutate } = useSWR( + `/api/inviteCodes`, + async (url) => { + const res = await fetchWithZod(InviteCodesSchema, url); + return res; + }, + { + onSuccess: async (data, key, config) => { + await onSuccessCallback?.(data, key, config); + }, + onError: (error, key, config) => { + toast.error("Error fetching user"); + onErrorCallback?.(error, key, config); + }, + } + ); + + return { + inviteCodes: data, + isLoading: !error && !data, + isError: error, + mutate, + }; +} diff --git a/apps/recnet/src/types/inviteCode.ts b/apps/recnet/src/types/inviteCode.ts new file mode 100644 index 00000000..fef63b99 --- /dev/null +++ b/apps/recnet/src/types/inviteCode.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { FirebaseTsSchema } from "./rec"; +import { UserSchema } from "./user"; + +export const inviteCodeSchema = z.object({ + id: z.string(), + used: z.boolean(), + usedAt: FirebaseTsSchema.optional(), + usedBy: UserSchema.optional(), + issuedTo: UserSchema.optional(), +}); + +export type InviteCode = z.infer; From b9352b03064559cb4b99750232988518ce2367c1 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 12:05:16 -0500 Subject: [PATCH 19/41] Add invite code table to admin page --- .../app/admin/invite-code/monitor/page.tsx | 49 ++++++++++++++++++- apps/recnet/src/app/api/inviteCodes/route.ts | 8 ++- apps/recnet/src/hooks/useInviteCodes.ts | 2 +- apps/recnet/src/types/inviteCode.ts | 6 +-- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx index 3cf828bd..5580de00 100644 --- a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx +++ b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx @@ -3,6 +3,30 @@ import { cn } from "@/utils/cn"; import { AdminSectionBox, AdminSectionTitle } from "@/app/admin/AdminSections"; import { useInviteCodes } from "@/hooks/useInviteCodes"; +import { Table } from "@radix-ui/themes"; +import { InviteCode } from "@/types/inviteCode"; +import { getDateFromFirebaseTimestamp, formatDate } from "@/utils/date"; + +const InviteCodeTableRow = (props: { inviteCode: InviteCode }) => { + const { inviteCode } = props; + return ( + + {inviteCode.id} + {inviteCode.used ? "Yes" : "No"} + + {inviteCode.usedAt + ? formatDate(getDateFromFirebaseTimestamp(inviteCode.usedAt)) + : "-"} + + + {inviteCode.usedBy ? inviteCode.usedBy.email : "-"} + + + {inviteCode.issuedTo ? inviteCode.issuedTo.email : "-"} + + + ); +}; export default function InviteCodeMonitorPage() { const { inviteCodes } = useInviteCodes(); @@ -14,7 +38,30 @@ export default function InviteCodeMonitorPage() { Invite Code Monitor - 123 + + + + + Code + Used + Used At + Used By + Referrer + + + + + {inviteCodes + ? inviteCodes.map((inviteCode) => ( + + )) + : null} + + +
); diff --git a/apps/recnet/src/app/api/inviteCodes/route.ts b/apps/recnet/src/app/api/inviteCodes/route.ts index 1237de0f..cf5605d4 100644 --- a/apps/recnet/src/app/api/inviteCodes/route.ts +++ b/apps/recnet/src/app/api/inviteCodes/route.ts @@ -53,7 +53,13 @@ export async function GET() { .filter(notEmpty); return Response.json({ - inviteCodes: inviteCodesWithUsers, + inviteCodes: inviteCodesWithUsers.sort((a, b) => { + // sort by usedAt + if (a.usedAt && b.usedAt) { + return b.usedAt._seconds - a.usedAt._seconds; + } + return (b.used ? 1 : 0) - (a.used ? 1 : 0); + }), }); } catch (error) { return Response.json(null, { diff --git a/apps/recnet/src/hooks/useInviteCodes.ts b/apps/recnet/src/hooks/useInviteCodes.ts index 10bebd94..9f822187 100644 --- a/apps/recnet/src/hooks/useInviteCodes.ts +++ b/apps/recnet/src/hooks/useInviteCodes.ts @@ -42,7 +42,7 @@ export function useInviteCodes(options?: { ); return { - inviteCodes: data, + inviteCodes: data?.inviteCodes, isLoading: !error && !data, isError: error, mutate, diff --git a/apps/recnet/src/types/inviteCode.ts b/apps/recnet/src/types/inviteCode.ts index fef63b99..86a875f2 100644 --- a/apps/recnet/src/types/inviteCode.ts +++ b/apps/recnet/src/types/inviteCode.ts @@ -5,9 +5,9 @@ import { UserSchema } from "./user"; export const inviteCodeSchema = z.object({ id: z.string(), used: z.boolean(), - usedAt: FirebaseTsSchema.optional(), - usedBy: UserSchema.optional(), - issuedTo: UserSchema.optional(), + usedAt: FirebaseTsSchema.optional().nullable(), + usedBy: UserSchema.optional().nullable(), + issuedTo: UserSchema.optional().nullable(), }); export type InviteCode = z.infer; From b5b8da9aed586eb2c93778fc0429c74efe4139a1 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 12:06:12 -0500 Subject: [PATCH 20/41] Add TODO item for clearing console logs --- .github/pull_request_template.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a24a8ac6..2d271ce7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -16,3 +16,4 @@ ## TODO - [ ] Paste the testing link +- [ ] Clear `console.log` or `console.error` for debug usage From dee9c38e3a5ebca40e905305ed153d4d998ea371 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 12:30:37 -0500 Subject: [PATCH 21/41] Add MIT License --- LICENSE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE.md diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..4c7828e7 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 LIL Lab + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From d12f6acddbd3d935e8abc30b08092447a06a9c98 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 12:38:10 -0500 Subject: [PATCH 22/41] Refactor invite code monitor page and add useCopyToClipboard hook --- .../app/admin/invite-code/monitor/page.tsx | 111 +++++++++++++----- apps/recnet/src/hooks/useCopyToClipboard.ts | 32 +++++ 2 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 apps/recnet/src/hooks/useCopyToClipboard.ts diff --git a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx index 5580de00..4e4fb4d6 100644 --- a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx +++ b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx @@ -3,23 +3,65 @@ import { cn } from "@/utils/cn"; import { AdminSectionBox, AdminSectionTitle } from "@/app/admin/AdminSections"; import { useInviteCodes } from "@/hooks/useInviteCodes"; -import { Table } from "@radix-ui/themes"; +import { Flex, Table, Text } from "@radix-ui/themes"; import { InviteCode } from "@/types/inviteCode"; import { getDateFromFirebaseTimestamp, formatDate } from "@/utils/date"; +import { Avatar } from "@/components/Avatar"; +import { RecNetLink } from "@/components/Link"; +import { CopyIcon } from "@radix-ui/react-icons"; +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; +import { toast } from "sonner"; +import { TailSpin } from "react-loader-spinner"; const InviteCodeTableRow = (props: { inviteCode: InviteCode }) => { const { inviteCode } = props; + const { copy } = useCopyToClipboard(); + return ( - - {inviteCode.id} - {inviteCode.used ? "Yes" : "No"} + + + { + copy(inviteCode.id).then(() => { + // toast + toast.success("Copied to clipboard!"); + }); + }} + > + {inviteCode.id} + + + + + + {inviteCode.used ? "Yes" : "No"} + + {inviteCode.usedAt ? formatDate(getDateFromFirebaseTimestamp(inviteCode.usedAt)) : "-"} - {inviteCode.usedBy ? inviteCode.usedBy.email : "-"} + {inviteCode.usedBy ? ( + + + + {inviteCode.usedBy.displayName} + + + ) : ( + "-" + )} {inviteCode.issuedTo ? inviteCode.issuedTo.email : "-"} @@ -29,38 +71,49 @@ const InviteCodeTableRow = (props: { inviteCode: InviteCode }) => { }; export default function InviteCodeMonitorPage() { - const { inviteCodes } = useInviteCodes(); - console.log("inviteCodes", inviteCodes); + const { inviteCodes, isLoading } = useInviteCodes(); return ( -
+
Invite Code Monitor - - - - Code - Used - Used At - Used By - Referrer - - + {inviteCodes && !isLoading ? ( + + + + Code + Used + Used At + Used By + Referrer + + - - {inviteCodes - ? inviteCodes.map((inviteCode) => ( - - )) - : null} - - + + {inviteCodes.map((inviteCode) => ( + + ))} + + + ) : ( + + + + )}
diff --git a/apps/recnet/src/hooks/useCopyToClipboard.ts b/apps/recnet/src/hooks/useCopyToClipboard.ts new file mode 100644 index 00000000..b8c94de7 --- /dev/null +++ b/apps/recnet/src/hooks/useCopyToClipboard.ts @@ -0,0 +1,32 @@ +import { useCallback, useState } from "react"; + +// ref: https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useCopyToClipboard/useCopyToClipboard.ts +type CopiedValue = string | null; +type CopyFn = (text: string) => Promise; + +export function useCopyToClipboard() { + const [copiedText, setCopiedText] = useState(null); + + const copy: CopyFn = useCallback(async (text) => { + if (!navigator?.clipboard) { + console.warn("Clipboard not supported"); + return false; + } + + // Try to save to clipboard then save it in the state if worked + try { + await navigator.clipboard.writeText(text); + setCopiedText(text); + return true; + } catch (error) { + console.warn("Copy failed", error); + setCopiedText(null); + return false; + } + }, []); + + return { + copiedText, + copy, + }; +} From 99c03adf9ebdc1c10de458e22af4391c3517334f Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 12:43:56 -0500 Subject: [PATCH 23/41] Add AdminSectionTitle component to UserRecStats page --- apps/recnet/src/app/admin/stats/user-rec/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/recnet/src/app/admin/stats/user-rec/page.tsx b/apps/recnet/src/app/admin/stats/user-rec/page.tsx index 70a01a40..7dc0daf5 100644 --- a/apps/recnet/src/app/admin/stats/user-rec/page.tsx +++ b/apps/recnet/src/app/admin/stats/user-rec/page.tsx @@ -8,6 +8,7 @@ import groupBy from "lodash.groupby"; import { RecSchema } from "@/types/rec"; import { notEmpty } from "@/utils/notEmpty"; import { RecsCycleBarChart } from "./RecsCycleBarChart"; +import { AdminSectionTitle } from "../../AdminSections"; const CurrentUserCount = withSuspense( async () => { @@ -108,6 +109,7 @@ const RecsBarChart = withSuspense( export default async function UserRecStats() { return (
+ User & Recommendation Stats
From a4f5dccd24b5dabe1e980cc940b72b08bf23cd1a Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 15:48:09 -0500 Subject: [PATCH 24/41] add invite code generation section --- apps/recnet/src/app/admin/AdminPanelNav.tsx | 1 - apps/recnet/src/app/admin/AdminSections.tsx | 3 +- .../app/admin/invite-code/monitor/page.tsx | 2 +- .../app/admin/invite-code/provision/page.tsx | 66 +++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 apps/recnet/src/app/admin/invite-code/provision/page.tsx diff --git a/apps/recnet/src/app/admin/AdminPanelNav.tsx b/apps/recnet/src/app/admin/AdminPanelNav.tsx index d6be2a67..186a542f 100644 --- a/apps/recnet/src/app/admin/AdminPanelNav.tsx +++ b/apps/recnet/src/app/admin/AdminPanelNav.tsx @@ -126,7 +126,6 @@ export function AdminPanelNavbar() { diff --git a/apps/recnet/src/app/admin/AdminSections.tsx b/apps/recnet/src/app/admin/AdminSections.tsx index 21dcdb59..3099cf69 100644 --- a/apps/recnet/src/app/admin/AdminSections.tsx +++ b/apps/recnet/src/app/admin/AdminSections.tsx @@ -31,7 +31,8 @@ export function AdminSectionBox(props: { children: React.ReactNode }) { "p-4", "border-[1px]", "rounded-4", - "border-gray-6" + "border-gray-6", + "mb-4" )} > {children} diff --git a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx index 4e4fb4d6..a26a2c55 100644 --- a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx +++ b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx @@ -88,7 +88,7 @@ export default function InviteCodeMonitorPage() { Used Used At Used By - Referrer + Owner diff --git a/apps/recnet/src/app/admin/invite-code/provision/page.tsx b/apps/recnet/src/app/admin/invite-code/provision/page.tsx new file mode 100644 index 00000000..62d8daef --- /dev/null +++ b/apps/recnet/src/app/admin/invite-code/provision/page.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { cn } from "@/utils/cn"; +import { AdminSectionBox, AdminSectionTitle } from "../../AdminSections"; +import { Button, Flex, Text, TextField } from "@radix-ui/themes"; +import { AtSignIcon, HashIcon } from "lucide-react"; + +function InviteCodeGenerateForm() { + return ( +
+ +
+ + Number of Codes + + + + + + + +
+
+ + {`Owner's user handle`} + + + + + + + +
+
+ +
+
+
+ ); +} + +export default function InviteCodeProvision() { + return ( +
+
+ + Generate Invite Code + + + + + + Provision Invite Code + + + Work In Progress 🚧 + +
+
+ ); +} From 8e84bb8f43256bfd7d764674ff6bac3d31c80aad Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 17:05:59 -0500 Subject: [PATCH 25/41] refactor: refactor invite code monitor page --- .../app/admin/invite-code/monitor/page.tsx | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx index a26a2c55..57370b31 100644 --- a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx +++ b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx @@ -12,6 +12,19 @@ import { CopyIcon } from "@radix-ui/react-icons"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import { toast } from "sonner"; import { TailSpin } from "react-loader-spinner"; +import { User } from "@/types/user"; + +const TableUserCard = (props: { user: User }) => { + const { user } = props; + return ( + + + + {user.displayName} + + + ); +}; const InviteCodeTableRow = (props: { inviteCode: InviteCode }) => { const { inviteCode } = props; @@ -49,23 +62,15 @@ const InviteCodeTableRow = (props: { inviteCode: InviteCode }) => { : "-"} - {inviteCode.usedBy ? ( - - - - {inviteCode.usedBy.displayName} - - + {inviteCode.usedBy ? : "-"} + + + {inviteCode.issuedTo ? ( + ) : ( "-" )} - - {inviteCode.issuedTo ? inviteCode.issuedTo.email : "-"} - ); }; @@ -74,7 +79,7 @@ export default function InviteCodeMonitorPage() { const { inviteCodes, isLoading } = useInviteCodes(); return ( -
+
Invite Code Monitor From 21014fd4fa9f85903b1075a700e538c7c61e51b0 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 17:07:30 -0500 Subject: [PATCH 26/41] chore: clear console.log --- apps/recnet/src/app/admin/stats/user-rec/page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/recnet/src/app/admin/stats/user-rec/page.tsx b/apps/recnet/src/app/admin/stats/user-rec/page.tsx index 7dc0daf5..4dc5bd6d 100644 --- a/apps/recnet/src/app/admin/stats/user-rec/page.tsx +++ b/apps/recnet/src/app/admin/stats/user-rec/page.tsx @@ -39,7 +39,6 @@ const RecCount = withSuspense( const RecsThisCycle = withSuspense( async () => { const cutOff = getNextCutOff(); - console.log("cutOff", cutOff); const recsThisCycle = await db .collection("recommendations") .where("cutoff", "==", Timestamp.fromMillis(cutOff.getTime())) From 154c6641015f139de50bcfcaac30daccae11544e Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 17:08:58 -0500 Subject: [PATCH 27/41] feat(invite code provision page): add invite code generation feature --- .../app/admin/invite-code/provision/page.tsx | 104 ++++++++++++++++-- apps/recnet/src/server/inviteCode.ts | 36 ++++++ 2 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 apps/recnet/src/server/inviteCode.ts diff --git a/apps/recnet/src/app/admin/invite-code/provision/page.tsx b/apps/recnet/src/app/admin/invite-code/provision/page.tsx index 62d8daef..d4ea1d82 100644 --- a/apps/recnet/src/app/admin/invite-code/provision/page.tsx +++ b/apps/recnet/src/app/admin/invite-code/provision/page.tsx @@ -4,10 +4,68 @@ import { cn } from "@/utils/cn"; import { AdminSectionBox, AdminSectionTitle } from "../../AdminSections"; import { Button, Flex, Text, TextField } from "@radix-ui/themes"; import { AtSignIcon, HashIcon } from "lucide-react"; +import { set, z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { toast } from "sonner"; +import { generateInviteCode } from "@/server/inviteCode"; +import { useAuth } from "@/app/AuthContext"; +import { User, UserSchema } from "@/types/user"; +import { fetchWithZod } from "@/utils/zodFetch"; + +const InviteCodeGenerationSchema = z.object({ + count: z.coerce.number().min(1).max(5, "Max 5 invite codes at a time"), + owner: z.string().optional(), +}); function InviteCodeGenerateForm() { + const { user } = useAuth(); + const [newInviteCodes, setNewInviteCodes] = useState([]); + + const { register, handleSubmit, formState, setError } = useForm({ + resolver: zodResolver(InviteCodeGenerationSchema), + defaultValues: { + count: 1, + owner: undefined, + }, + mode: "onBlur", + }); + return ( -
+ { + e?.preventDefault(); + // check if owner exists + let owner: User | null; + if (data.owner) { + // check if owner exists + try { + const recipient = await fetchWithZod( + UserSchema, + `/api/userByUsername?username=${data.owner}` + ); + owner = recipient; + } catch (error) { + setError("owner", { + type: "manual", + message: "User not found", + }); + return; + } + } else { + owner = user; + } + if (!owner) { + // should never happen, for type safety + return; + } + // generate invite codes + const codes = await generateInviteCode(owner.id, data.count); + // show modal with invite codes + setNewInviteCodes(codes); + })} + >
@@ -17,8 +75,13 @@ function InviteCodeGenerateForm() { - + + {formState.errors.count ? ( + + {formState.errors.count.message} + + ) : null}
@@ -29,13 +92,31 @@ function InviteCodeGenerateForm() { + {formState.errors.owner ? ( + + {formState.errors.owner.message} + + ) : null}
-
-
@@ -46,9 +127,18 @@ function InviteCodeGenerateForm() { export default function InviteCodeProvision() { return ( -
+
- + Generate Invite Code diff --git a/apps/recnet/src/server/inviteCode.ts b/apps/recnet/src/server/inviteCode.ts new file mode 100644 index 00000000..9352e3aa --- /dev/null +++ b/apps/recnet/src/server/inviteCode.ts @@ -0,0 +1,36 @@ +"use server"; +import { db } from "@/firebase/admin"; +import Chance from "chance"; +import { FieldValue } from "firebase-admin/firestore"; + +function getNewInviteCode() { + const chance = new Chance(); + const code = chance.string({ + length: 16, + pool: "abcdefghijklmnopqrstuvwxyz0123456789", + }); + // split the code into 4 parts + return (code.match(/.{1,4}/g) as string[]).join("-"); +} + +export async function generateInviteCode(ownerId: string, num: number) { + const inviteCodes = Array.from({ length: num }, () => { + return getNewInviteCode(); + }); + + await Promise.all( + inviteCodes.map(async (inviteCode) => { + await db.collection("invite-codes").doc(inviteCode).set({ + id: inviteCode, + issuedTo: ownerId, + createdAt: FieldValue.serverTimestamp(), + issuedAt: FieldValue.serverTimestamp(), + used: false, + usedAt: null, + usedBy: null, + }); + }) + ); + + return inviteCodes; +} From 66c3d7c7015432bb4d42fc3288e67c97b9268047 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 17:22:44 -0500 Subject: [PATCH 28/41] refactor(monitor page): refactor monitor page and extract component --- .../app/admin/invite-code/monitor/page.tsx | 97 ++++++++++++------- apps/recnet/src/components/InviteCode.tsx | 26 +++++ 2 files changed, 88 insertions(+), 35 deletions(-) create mode 100644 apps/recnet/src/components/InviteCode.tsx diff --git a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx index 57370b31..10ffe8d7 100644 --- a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx +++ b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx @@ -8,11 +8,9 @@ import { InviteCode } from "@/types/inviteCode"; import { getDateFromFirebaseTimestamp, formatDate } from "@/utils/date"; import { Avatar } from "@/components/Avatar"; import { RecNetLink } from "@/components/Link"; -import { CopyIcon } from "@radix-ui/react-icons"; -import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; -import { toast } from "sonner"; import { TailSpin } from "react-loader-spinner"; import { User } from "@/types/user"; +import { CopiableInviteCode } from "@/components/InviteCode"; const TableUserCard = (props: { user: User }) => { const { user } = props; @@ -28,23 +26,11 @@ const TableUserCard = (props: { user: User }) => { const InviteCodeTableRow = (props: { inviteCode: InviteCode }) => { const { inviteCode } = props; - const { copy } = useCopyToClipboard(); return ( - { - copy(inviteCode.id).then(() => { - // toast - toast.success("Copied to clipboard!"); - }); - }} - > - {inviteCode.id} - - + { ); }; +const TableLoader = () => { + return ( + + + + ); +}; + export default function InviteCodeMonitorPage() { const { inviteCodes, isLoading } = useInviteCodes(); return (
- + Invite Code Monitor @@ -89,7 +91,40 @@ export default function InviteCodeMonitorPage() { - Code + + Code + + Used + Used At + Used By + Owner + + + + + {inviteCodes + .filter((c) => c.used) + .map((inviteCode) => ( + + ))} + + + ) : ( + + )} + + Unused Invite Codes + + {inviteCodes && !isLoading ? ( + + + + + Code + Used Used At Used By @@ -98,26 +133,18 @@ export default function InviteCodeMonitorPage() { - {inviteCodes.map((inviteCode) => ( - - ))} + {inviteCodes + .filter((c) => !c.used) + .map((inviteCode) => ( + + ))} ) : ( - - - + )}
diff --git a/apps/recnet/src/components/InviteCode.tsx b/apps/recnet/src/components/InviteCode.tsx new file mode 100644 index 00000000..e92a87e8 --- /dev/null +++ b/apps/recnet/src/components/InviteCode.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Flex } from "@radix-ui/themes"; +import { CopyIcon } from "@radix-ui/react-icons"; +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; +import { toast } from "sonner"; + +export const CopiableInviteCode = (props: { inviteCode: string }) => { + const { inviteCode } = props; + const { copy } = useCopyToClipboard(); + + return ( + { + copy(inviteCode).then(() => { + // toast + toast.success("Copied to clipboard!"); + }); + }} + > + {inviteCode} + + + ); +}; From c270483afeddcd1db5a91fdc3bb3887dbb3ce187 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 17:33:52 -0500 Subject: [PATCH 29/41] Refactor invite code table in admin monitor page --- .../app/admin/invite-code/monitor/page.tsx | 108 ++++++++++-------- 1 file changed, 59 insertions(+), 49 deletions(-) diff --git a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx index 10ffe8d7..7136073e 100644 --- a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx +++ b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx @@ -4,7 +4,6 @@ import { cn } from "@/utils/cn"; import { AdminSectionBox, AdminSectionTitle } from "@/app/admin/AdminSections"; import { useInviteCodes } from "@/hooks/useInviteCodes"; import { Flex, Table, Text } from "@radix-ui/themes"; -import { InviteCode } from "@/types/inviteCode"; import { getDateFromFirebaseTimestamp, formatDate } from "@/utils/date"; import { Avatar } from "@/components/Avatar"; import { RecNetLink } from "@/components/Link"; @@ -24,43 +23,6 @@ const TableUserCard = (props: { user: User }) => { ); }; -const InviteCodeTableRow = (props: { inviteCode: InviteCode }) => { - const { inviteCode } = props; - - return ( - - - - - - - {inviteCode.used ? "Yes" : "No"} - - - - {inviteCode.usedAt - ? formatDate(getDateFromFirebaseTimestamp(inviteCode.usedAt)) - : "-"} - - - {inviteCode.usedBy ? : "-"} - - - {inviteCode.issuedTo ? ( - - ) : ( - "-" - )} - - - ); -}; - const TableLoader = () => { return ( @@ -97,7 +59,7 @@ export default function InviteCodeMonitorPage() { Used Used At Used By - Owner + Referrer @@ -105,10 +67,42 @@ export default function InviteCodeMonitorPage() { {inviteCodes .filter((c) => c.used) .map((inviteCode) => ( - + + + + + + + {inviteCode.used ? "Yes" : "No"} + + + + {inviteCode.usedAt + ? formatDate( + getDateFromFirebaseTimestamp(inviteCode.usedAt) + ) + : "-"} + + + {inviteCode.usedBy ? ( + + ) : ( + "-" + )} + + + {inviteCode.issuedTo ? ( + + ) : ( + "-" + )} + + ))} @@ -126,8 +120,6 @@ export default function InviteCodeMonitorPage() { Code Used - Used At - Used By Owner @@ -136,10 +128,28 @@ export default function InviteCodeMonitorPage() { {inviteCodes .filter((c) => !c.used) .map((inviteCode) => ( - + + + + + + + {inviteCode.used ? "Yes" : "No"} + + + + {inviteCode.issuedTo ? ( + + ) : ( + "-" + )} + + ))} From 6cddb1145c9b0fe85f0cf58eef864af1cc9183ba Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 17:43:58 -0500 Subject: [PATCH 30/41] feat: finish generate invite code --- .../app/admin/invite-code/provision/page.tsx | 207 ++++++++++-------- apps/recnet/src/components/InviteCode.tsx | 9 +- 2 files changed, 127 insertions(+), 89 deletions(-) diff --git a/apps/recnet/src/app/admin/invite-code/provision/page.tsx b/apps/recnet/src/app/admin/invite-code/provision/page.tsx index d4ea1d82..dbab1708 100644 --- a/apps/recnet/src/app/admin/invite-code/provision/page.tsx +++ b/apps/recnet/src/app/admin/invite-code/provision/page.tsx @@ -2,17 +2,17 @@ import { cn } from "@/utils/cn"; import { AdminSectionBox, AdminSectionTitle } from "../../AdminSections"; -import { Button, Flex, Text, TextField } from "@radix-ui/themes"; +import { Button, Flex, Text, TextField, Dialog } from "@radix-ui/themes"; import { AtSignIcon, HashIcon } from "lucide-react"; -import { set, z } from "zod"; +import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useState } from "react"; -import { toast } from "sonner"; import { generateInviteCode } from "@/server/inviteCode"; import { useAuth } from "@/app/AuthContext"; import { User, UserSchema } from "@/types/user"; import { fetchWithZod } from "@/utils/zodFetch"; +import { CopiableInviteCode } from "@/components/InviteCode"; const InviteCodeGenerationSchema = z.object({ count: z.coerce.number().min(1).max(5, "Max 5 invite codes at a time"), @@ -22,6 +22,7 @@ const InviteCodeGenerationSchema = z.object({ function InviteCodeGenerateForm() { const { user } = useAuth(); const [newInviteCodes, setNewInviteCodes] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); const { register, handleSubmit, formState, setError } = useForm({ resolver: zodResolver(InviteCodeGenerationSchema), @@ -33,95 +34,127 @@ function InviteCodeGenerateForm() { }); return ( - { - e?.preventDefault(); - // check if owner exists - let owner: User | null; - if (data.owner) { + <> + { + e?.preventDefault(); // check if owner exists - try { - const recipient = await fetchWithZod( - UserSchema, - `/api/userByUsername?username=${data.owner}` - ); - owner = recipient; - } catch (error) { - setError("owner", { - type: "manual", - message: "User not found", - }); + let owner: User | null; + if (data.owner) { + // check if owner exists + try { + const recipient = await fetchWithZod( + UserSchema, + `/api/userByUsername?username=${data.owner}` + ); + owner = recipient; + } catch (error) { + setError("owner", { + type: "manual", + message: "User not found", + }); + return; + } + } else { + owner = user; + } + if (!owner) { + // should never happen, for type safety return; } - } else { - owner = user; - } - if (!owner) { - // should never happen, for type safety - return; - } - // generate invite codes - const codes = await generateInviteCode(owner.id, data.count); - // show modal with invite codes - setNewInviteCodes(codes); - })} - > - -
- - Number of Codes - - - - - - - - {formState.errors.count ? ( - - {formState.errors.count.message} + // generate invite codes + const codes = await generateInviteCode(owner.id, data.count); + // show modal with invite codes + setNewInviteCodes(codes); + setIsModalOpen(true); + })} + > + +
+ + Number of Codes + + + + + + + + {formState.errors.count ? ( + + {formState.errors.count.message} + + ) : null} +
+
+ + {`Owner's user handle`} - ) : null} -
-
- - {`Owner's user handle`} - - - - - - - - {formState.errors.owner ? ( - - {formState.errors.owner.message} + + + + + + + {formState.errors.owner ? ( + + {formState.errors.owner.message} + + ) : null} +
+
+ + {`generate`} - ) : null} -
-
- - {`generate`} - - -
-
- + +
+
+ + { + if (!open) { + setNewInviteCodes([]); + } + setIsModalOpen(open); + }} + > + + Your invite codes are generated 🚀 + + {`Share these codes with your friends and family to invite them to + RecNet! You can view these codes in the "Invite Code Monitor" page.`} + + + + {newInviteCodes.map((code) => ( + + ))} + + + + + + + + + + ); } diff --git a/apps/recnet/src/components/InviteCode.tsx b/apps/recnet/src/components/InviteCode.tsx index e92a87e8..f1e1fb8c 100644 --- a/apps/recnet/src/components/InviteCode.tsx +++ b/apps/recnet/src/components/InviteCode.tsx @@ -1,6 +1,6 @@ "use client"; -import { Flex } from "@radix-ui/themes"; +import { Flex, Text } from "@radix-ui/themes"; import { CopyIcon } from "@radix-ui/react-icons"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import { toast } from "sonner"; @@ -19,7 +19,12 @@ export const CopiableInviteCode = (props: { inviteCode: string }) => { }); }} > - {inviteCode} + + {inviteCode} +
); From 7883379ab3a97973fb0ab585907868b719b65102 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 22:35:38 -0500 Subject: [PATCH 31/41] Refactor table layout and styling --- .../app/admin/invite-code/monitor/page.tsx | 34 ++++--------------- apps/recnet/src/components/InviteCode.tsx | 4 +-- 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx index 7136073e..6ed32dda 100644 --- a/apps/recnet/src/app/admin/invite-code/monitor/page.tsx +++ b/apps/recnet/src/app/admin/invite-code/monitor/page.tsx @@ -17,7 +17,7 @@ const TableUserCard = (props: { user: User }) => { - {user.displayName} + {user.displayName} ); @@ -50,14 +50,13 @@ export default function InviteCodeMonitorPage() { {inviteCodes && !isLoading ? ( - - + + - + Code Used - Used At Used By Referrer @@ -71,16 +70,6 @@ export default function InviteCodeMonitorPage() { - - - {inviteCode.used ? "Yes" : "No"} - - {inviteCode.usedAt ? formatDate( @@ -114,12 +103,11 @@ export default function InviteCodeMonitorPage() { {inviteCodes && !isLoading ? ( - + - + Code - Used Owner @@ -132,16 +120,6 @@ export default function InviteCodeMonitorPage() { - - - {inviteCode.used ? "Yes" : "No"} - - {inviteCode.issuedTo ? ( diff --git a/apps/recnet/src/components/InviteCode.tsx b/apps/recnet/src/components/InviteCode.tsx index f1e1fb8c..de32c8eb 100644 --- a/apps/recnet/src/components/InviteCode.tsx +++ b/apps/recnet/src/components/InviteCode.tsx @@ -11,7 +11,7 @@ export const CopiableInviteCode = (props: { inviteCode: string }) => { return ( { copy(inviteCode).then(() => { // toast @@ -20,7 +20,7 @@ export const CopiableInviteCode = (props: { inviteCode: string }) => { }} > {inviteCode} From 53a295feedeb9ceda482b26697ed92ecfe353cc9 Mon Sep 17 00:00:00 2001 From: swh00tw Date: Tue, 5 Mar 2024 22:45:25 -0500 Subject: [PATCH 32/41] Refactor invite code generation form layout --- .../src/app/admin/invite-code/provision/page.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/recnet/src/app/admin/invite-code/provision/page.tsx b/apps/recnet/src/app/admin/invite-code/provision/page.tsx index dbab1708..c3f7dc3b 100644 --- a/apps/recnet/src/app/admin/invite-code/provision/page.tsx +++ b/apps/recnet/src/app/admin/invite-code/provision/page.tsx @@ -69,8 +69,14 @@ function InviteCodeGenerateForm() { setIsModalOpen(true); })} > - -
+ +
Number of Codes @@ -86,7 +92,7 @@ function InviteCodeGenerateForm() { ) : null}
-
+
{`Owner's user handle`} @@ -107,7 +113,7 @@ function InviteCodeGenerateForm() { ) : null}
- + {`generate`}