diff --git a/config.ts b/config.ts index bd347d1..b9d67eb 100644 --- a/config.ts +++ b/config.ts @@ -1,6 +1,7 @@ /** * @description Global configuration file for the application */ +import * as process from "node:process"; // POCKETBASE export const PB_USER_COLLECTION = "users" @@ -11,6 +12,7 @@ export const PB_STORAGE_KEY = "stuve-it-login-record" export const APP_NAME = "StuVe IT" export const APP_VERSION = "0.9.8 (beta)" export const APP_URL = "https://it.stuve.uni-ulm.de" +export const LOCAL_DEV_MODE = process?.env?.NODE_ENV === "development" || window?.location?.hostname === "localhost" // analytics export const ANALYTICS_COOKIE_NAME = "stuve-it-analytics" diff --git a/package.json b/package.json index 5ef9b4e..dea6b95 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@fontsource/fira-code": "^5.0.15", "@fontsource/overpass": "^5.0.15", "@hello-pangea/dnd": "^16.6.0", + "@mantine/charts": "^7.13.4", "@mantine/code-highlight": "^7.10.0", "@mantine/core": "^7.10.0", "@mantine/dates": "^7.10.0", @@ -35,6 +36,7 @@ "@tiptap/starter-kit": "^2.3.0", "@tiptap/suggestion": "^2.4.0", "@types/react-big-calendar": "^1.8.9", + "@uidotdev/usehooks": "^2.4.1", "@yudiel/react-qr-scanner": "^2.0.2", "clsx": "^2.1.1", "dayjs": "^1.11.10", @@ -46,16 +48,16 @@ "papaparse": "^5.4.1", "pocketbase": "^0.19.0", "react": "^18.2.0", - "react-beforeunload": "^2.6.0", "react-big-calendar": "^1.11.3", "react-cookie": "^7.2.2", + "react-countup": "^6.5.3", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-error-boundary": "^4.1.2", - "react-infinite-scroll-component": "^6.1.0", "react-intersection-observer": "^9.10.2", "react-markdown": "^9.0.1", "react-router-dom": "^6.20.0", + "recharts": "2", "recoil": "^0.7.7", "remark-gfm": "^4.0.0", "sanitize-html": "^2.13.0", @@ -84,7 +86,7 @@ "postcss-simple-vars": "^7.0.1", "sass": "^1.75.0", "typescript": "^5.0.2", - "vite": "5.1.0-beta.2", + "vite": "5.4.6", "vite-plugin-svgr": "^4.2.0" } } diff --git a/public/stuve-logo-debug.svg b/public/stuve-logo-debug.svg new file mode 100644 index 0000000..a99b123 --- /dev/null +++ b/public/stuve-logo-debug.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Router.tsx b/src/Router.tsx index 31040c8..5f63490 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -9,14 +9,17 @@ import DebugPage from "@/pages/debug/DebugPage.tsx"; import EmailRouter from "@/pages/email/EmailRouter.tsx"; import AdminRouter from "@/pages/admin/AdminRouter.tsx"; import Analytics from "@/components/analytics"; +import {ErrorBoundary} from "react-error-boundary"; +import ErrorPage from "@/pages/error-pages/ErrorPage.tsx"; +import WhatWeKnowAboutYou from "@/pages/util/whatWeKnowAboutYou"; const router = createBrowserRouter([ { path: "/", - element: <> + element: }> - , + , children: [ { index: true, @@ -48,6 +51,10 @@ const router = createBrowserRouter([ { path: "qr", element: + }, + { + path : "whatWeKnowAboutYou", + element: } ] }, diff --git a/src/components/analytics/index.tsx b/src/components/analytics/index.tsx index 1a0f522..12efa8b 100644 --- a/src/components/analytics/index.tsx +++ b/src/components/analytics/index.tsx @@ -3,7 +3,6 @@ import {IconThumbUp} from "@tabler/icons-react"; import {Link} from "react-router-dom"; import useAnalyticsVisitor from "@/components/analytics/useAnalyticsVisitor.ts"; import useAnalyticsSession from "@/components/analytics/useAnalyticsSession.ts"; -import useAnalyticsError from "@/components/analytics/useAnalyticsError.ts"; import useAnalyticsPageView from "@/components/analytics/useAnalyticsPageView.ts"; /** @@ -19,42 +18,38 @@ export default function Analytics() { const {sessionId} = useAnalyticsSession(visitorId) // Track page views with the session ID useAnalyticsPageView(sessionId) - // Track errors - useAnalyticsError(sessionId) return ( - <> - -
- - Wir nutzen Cookies um diese Seite zu verbessern. - - - Mit der Nutzung unserer Dienste erklärst du dich damit einverstanden, - dass wir Cookies verwenden und anonymisierte Nutzungsdaten erheben. - + +
+ + Wir nutzen Cookies um diese Seite zu verbessern. + + + Mit der Nutzung unserer Dienste erklärst du dich damit einverstanden, + dass wir Cookies verwenden und anonymisierte Nutzungsdaten erheben. + -
- +
+ - -
+
-
- +
+
) } \ No newline at end of file diff --git a/src/components/analytics/useAnalyticsError.ts b/src/components/analytics/useAnalyticsError.ts deleted file mode 100644 index 84e67eb..0000000 --- a/src/components/analytics/useAnalyticsError.ts +++ /dev/null @@ -1,84 +0,0 @@ -import {useEffect} from "react"; -import {usePB} from "@/lib/pocketbase.tsx"; -import {useMutation} from "@tanstack/react-query"; -import {useLocation} from "react-router-dom"; - -/** - * Custom hook to track and log errors for analytics. - * - * @param {string} [sessionId] - The ID of the current analytics session. - */ -export default function useAnalyticsError(sessionId?: string) { - - const {pb} = usePB() - const location = useLocation() - - /** - * Mutation to log an error in the analytics system. - * - * @param {Object} error - The error object containing error details. - * @param {string} error.session - The session ID. - * @param {string} error.error_message - The error message. - * @param {string} error.error_type - The type of error. - * @param {string} error.stack_trace - The stack trace of the error. - */ - const trackErrorMutation = useMutation({ - mutationFn: async (error: { - session: string; - error_message: string; - error_type: string; - stack_trace: string; - }) => { - if (!sessionId) return - return await pb.collection('analyticsErrors').create({ - ...error, - path: location.pathname - }) - } - }) - - useEffect(() => { - // Check if current session is available - if (!sessionId) return; - - /** - * Global error handler to track errors. - * - * @param {ErrorEvent} event - The error event. - */ - const errorHandler = function (event: ErrorEvent) { - trackErrorMutation.mutate({ - session: sessionId, - error_message: event.message || 'Unknown error', - error_type: 'Global Error', - stack_trace: event.error?.stack || '', - }) - } - - /** - * Unhandled rejection handler to track promise rejections. - * - * @param {PromiseRejectionEvent} event - The promise rejection event. - */ - const unhandledRejectionHandler = function (event: PromiseRejectionEvent) { - trackErrorMutation.mutate({ - session: sessionId, - error_message: event.reason?.message || 'Unhandled Rejection', - error_type: 'Promise Rejection', - stack_trace: event.reason?.stack || '', - }) - } - - // Add event listeners - window.addEventListener('error', errorHandler) - window.addEventListener('unhandledrejection', unhandledRejectionHandler) - - // Cleanup function in useEffect - return () => { - window.removeEventListener('error', errorHandler) - window.removeEventListener('unhandledrejection', unhandledRejectionHandler) - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sessionId]) -} \ No newline at end of file diff --git a/src/components/analytics/useAnalyticsPageView.ts b/src/components/analytics/useAnalyticsPageView.ts index e08812b..bf29256 100644 --- a/src/components/analytics/useAnalyticsPageView.ts +++ b/src/components/analytics/useAnalyticsPageView.ts @@ -2,35 +2,81 @@ import {useEffect} from "react"; import {useLocation} from "react-router-dom"; import {useMutation} from "@tanstack/react-query"; import {usePB} from "@/lib/pocketbase.tsx"; +import {AnalyticsErrorType} from "@/models/AnalyticsTypes.ts"; /** - * Custom hook to track page views for analytics. + * Custom hook to track page views and errors for analytics. * * @param {string} [sessionId] - The ID of the current analytics session. */ export default function useAnalyticsPageView(sessionId?: string) { - const location = useLocation() + const {pb} = usePB() + const location = useLocation() /** - * Mutation to log a page view in the analytics system. - * - * @param {string} path - The path of the page being viewed. + * Mutation to log an error in the analytics system. */ const pageViewMutation = useMutation({ - mutationFn: async (path: string) => { - await pb.collection("analyticsPageViews").create({ - path, + mutationFn: async (error?: { + error_type: AnalyticsErrorType + stack_trace: string + error_message: string + }) => { + if (!sessionId) return + return await pb.collection('analyticsPageViews').create({ session: sessionId, + path: location.pathname, + error: error }) } }) + useEffect(() => { + /** + * Global error handler to track errors. + * + * @param {ErrorEvent} event - The error event. + */ + const errorHandler = function (event: ErrorEvent) { + pageViewMutation.mutate({ + error_type: 'Error', + stack_trace: event.error?.stack || '', + error_message: event.message || 'Unknown error', + }) + } + + /** + * Unhandled rejection handler to track promise rejections. + * + * @param {PromiseRejectionEvent} event - The promise rejection event. + */ + const unhandledRejectionHandler = function (event: PromiseRejectionEvent) { + pageViewMutation.mutate({ + error_type: 'Promise Rejection', + stack_trace: event.reason?.stack || '', + error_message: event.reason?.message || 'Unhandled Rejection', + }) + } + + // Add event listeners + window.addEventListener('error', errorHandler) + window.addEventListener('unhandledrejection', unhandledRejectionHandler) + + // Cleanup function in useEffect + return () => { + window.removeEventListener('error', errorHandler) + window.removeEventListener('unhandledrejection', unhandledRejectionHandler) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]) + // Effect to log the page view when the session ID or location changes useEffect(() => { if (!sessionId) return - pageViewMutation.mutate(location.pathname) + pageViewMutation.mutate(undefined) // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, location]) } \ No newline at end of file diff --git a/src/components/analytics/useAnalyticsSession.ts b/src/components/analytics/useAnalyticsSession.ts index cc936ea..6e37681 100644 --- a/src/components/analytics/useAnalyticsSession.ts +++ b/src/components/analytics/useAnalyticsSession.ts @@ -6,6 +6,7 @@ import {UAParser} from "ua-parser-js"; import {AnalyticsIpApiResult} from "@/models/AnalyticsTypes.ts"; import {ANALYTICS_IP_API} from "../../../config.ts"; import {ofetch} from "ofetch"; +import {usePreferredLanguage} from "@uidotdev/usehooks"; /** * Custom hook to manage an analytics session. @@ -18,10 +19,12 @@ export default function useAnalyticsSession(visitorId?: string) { // State to store the session ID const [sessionId, setSessionId] = useState(undefined) const {pb} = usePB() + const preferredLanguage = usePreferredLanguage() // Mutation to initialize the analytics session const initSessionMutation = useMutation({ mutationFn: async () => { + // prevent initialization if the visitor ID is not set or if debug mode is enabled if (!visitorId) return // Parse the user agent to get device and browser information @@ -45,10 +48,12 @@ export default function useAnalyticsSession(visitorId?: string) { operating_system_version: result.os.version, user_agent: navigator.userAgent, ip_address: ip, - geo_country_code: country_code + geo_country_code: country_code, + preferred_language: preferredLanguage }) }, onSuccess: (data) => { + if (!data) return // Set the session ID on successful creation setSessionId(data?.id) } diff --git a/src/components/analytics/useAnalyticsVisitor.ts b/src/components/analytics/useAnalyticsVisitor.ts index 9669199..0a51d5c 100644 --- a/src/components/analytics/useAnalyticsVisitor.ts +++ b/src/components/analytics/useAnalyticsVisitor.ts @@ -1,10 +1,11 @@ import {useCookies} from "react-cookie"; -import {ANALYTICS_COOKIE_NAME} from "../../../config.ts"; +import {ANALYTICS_COOKIE_NAME, LOCAL_DEV_MODE} from "../../../config.ts"; import {usePB} from "@/lib/pocketbase.tsx"; import {nanoid} from "nanoid/non-secure"; import {useMutation} from "@tanstack/react-query"; import {ClientResponseError} from "pocketbase"; import {useEffect} from "react"; +import dayjs from "dayjs"; /** @@ -18,6 +19,14 @@ export default function useAnalyticsVisitor() { const {pb} = usePB() + const storeVisitorInCookie = (visitorId: string) => { + setCookie(ANALYTICS_COOKIE_NAME, visitorId, { + path: "/", + expires: dayjs().add(1, "year").toDate(), + sameSite: "strict" + }) + } + const initVisitor = () => { // create 15 chars long random string const visitorId = nanoid(15) @@ -25,7 +34,7 @@ export default function useAnalyticsVisitor() { // create visitor in database upsertVisitorMutation.mutateAsync(visitorId).then(() => { // set cookie - setCookie(ANALYTICS_COOKIE_NAME, visitorId, {path: "/"}) + storeVisitorInCookie(visitorId) }) } @@ -36,13 +45,25 @@ export default function useAnalyticsVisitor() { mutationFn: async (visitorId: string) => { try { // set visitor updated date to now - await pb.collection("analyticsVisitors").update(visitorId, {}) + await pb.collection("analyticsVisitors").update(visitorId, { + meta: { + localDevMode: LOCAL_DEV_MODE + } + }) } catch (e) { if ((e as ClientResponseError).status === 404) { // visitor does not exist, create it - await pb.collection("analyticsVisitors").create({id: visitorId}) + await pb.collection("analyticsVisitors").create({ + id: visitorId, + meta: { + localDevMode: LOCAL_DEV_MODE + } + }) } } + }, + onSuccess: () => { + storeVisitorInCookie(cookieValue as string) } }) @@ -54,7 +75,6 @@ export default function useAnalyticsVisitor() { // eslint-disable-next-line }, [cookieIsSet, cookieValue]) - return { visitorId: cookieValue as string | undefined, initVisitor diff --git a/src/components/layout/nav/MenuItems.tsx b/src/components/layout/nav/MenuItems.tsx index 05b669e..c70760c 100644 --- a/src/components/layout/nav/MenuItems.tsx +++ b/src/components/layout/nav/MenuItems.tsx @@ -4,13 +4,12 @@ import {Fragment} from "react"; import { IconBug, IconConfetti, + IconDatabaseSmile, IconHome, IconList, IconMailShare, - IconQrcode, - IconSectionSign + IconQrcode } from "@tabler/icons-react"; -import {useShowDebug} from "@/components/ShowDebug.tsx"; import {usePB} from "@/lib/pocketbase.tsx"; @@ -58,45 +57,12 @@ const NavItems = [ icon: IconQrcode, description: "Generiere einen QR Code", link: "/util/qr" - } - ] - }, - - { - section: "Rechtliches", - items: [ - { - title: "Impressum", - icon: IconSectionSign, - description: "Impressum", - link: "/legal/imprint" }, { - title: "Datenschutzerklärung", - icon: IconSectionSign, - description: "Datenschutzerklärung", - link: "/legal/privacy-policy" - }, - { - title: "AGB", - icon: IconSectionSign, - description: "AGB", - link: "/legal/terms-and-conditions" - } - ] - } -] - -const DebugMenuItems = [ - { - section: "Debug", - items: [ - { - title: "Debug Seite", - icon: IconBug, - description: "Debug", - link: "/debug", - color: "orange" + title: "Datenauskunft", + icon: IconDatabaseSmile, + description: "Was wissen wir über dich?", + link: "/util/whatWeKnowAboutYou" } ] } @@ -119,15 +85,10 @@ const AdminMenuItems = [ export default function MenuItems() { - const {showDebug} = useShowDebug() const {user} = usePB() let nav = NavItems - if (showDebug) { - nav = [...nav, ...DebugMenuItems] - } - if (user?.isAdmin) { nav = [...nav, ...AdminMenuItems] } diff --git a/src/components/layout/nav/index.tsx b/src/components/layout/nav/index.tsx index 096b03d..ee85edb 100644 --- a/src/components/layout/nav/index.tsx +++ b/src/components/layout/nav/index.tsx @@ -1,14 +1,18 @@ import {usePB} from "@/lib/pocketbase.tsx"; import classes from "./index.module.css"; -import {ActionIcon, Image, Menu, ThemeIcon} from "@mantine/core"; -import {IconChevronDown, IconLogin, IconUserStar} from "@tabler/icons-react"; +import {ActionIcon, Badge, Image, Menu, ThemeIcon} from "@mantine/core"; +import {IconBug, IconChevronDown, IconLogin, IconUserStar} from "@tabler/icons-react"; import MenuItems from "./MenuItems.tsx"; import {useLogin, useUserMenu} from "@/components/users/modals/hooks.ts"; +import {useShowDebug} from "@/components/ShowDebug.tsx"; +import {Link} from "react-router-dom"; export default function NavBar() { const {user} = usePB() + const {showDebug} = useShowDebug() + const {handler: userMenuHandler} = useUserMenu() const {handler: loginHandler} = useLogin() @@ -25,7 +29,7 @@ export default function NavBar() { {"StuVe @@ -36,14 +40,29 @@ export default function NavBar() { + +
+ + {showDebug && ( + } + > + DEBUG MODUS + + )} + {user ? <> + + { + formValues.values.authMethod === "ldap" ? + <> + Mitglieder der Verfassten Studierendenschaft können sich mit ihrem StuVe IT Account + anmelden. +
+ Falls du du keinen StuVe IT Account hast, kannst du einen + formValues.setFieldValue("authMethod", "guest")}> + {""} Gast Account {" "} + + nutzen. + : <> + Hier kannst du dich mit deinem Gast Account anmelden. + + } +
+ >( + data: T[] +): Record> { + const result: Record> = {}; + + // Get all unique keys from the data + const keysSet = new Set(); + data.forEach((obj) => { + Object.keys(obj).forEach((key) => keysSet.add(key)); + }); + const keys = Array.from(keysSet); + + // Initialize a Map for each key to store counts + const countsMaps: Record> = {}; + keys.forEach((key) => { + countsMaps[key] = new Map(); + }); + + // Count occurrences + data.forEach((item) => { + keys.forEach((key) => { + const value = item.hasOwnProperty(key) ? item[key] : undefined; + const countsMap = countsMaps[key]; + countsMap.set(value, (countsMap.get(value) || 0) + 1); + }); + }); + + // Convert Maps to arrays in the desired format + keys.forEach((key) => { + const countsArray = Array.from(countsMaps[key].entries()).map( + ([value, count]) => ({ + key: value, + count, + }) + ); + result[key] = countsArray; + }); + + return result; +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index c3929d0..620f7a8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,7 @@ import '@mantine/code-highlight/styles.layer.css'; import '@mantine/dates/styles.layer.css'; import '@mantine/tiptap/styles.layer.css'; import '@mantine/notifications/styles.layer.css'; +import '@mantine/charts/styles.css'; import {Alert, createTheme, DEFAULT_THEME, MantineProvider, mergeMantineTheme} from "@mantine/core"; import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; import {PocketBaseProvider} from "@/lib/pocketbase.tsx"; diff --git a/src/models/AnalyticsTypes.ts b/src/models/AnalyticsTypes.ts index d3f6c59..81a7bed 100644 --- a/src/models/AnalyticsTypes.ts +++ b/src/models/AnalyticsTypes.ts @@ -14,17 +14,27 @@ export type AnalyticsSessionModel = { user_agent?: string ip_address?: string geo_country_code?: string - expand: { - visitor: AnalyticsVisitorsModel + preferred_language?: string + expand?: { + visitor?: AnalyticsVisitorsModel, + analyticsErrors_via_session?: AnalyticsErrorModel[], + analyticsPageViews_via_session?: AnalyticsPageViewModel[] } } & RecordModel +export type AnalyticsErrorType = 'Error' | 'Promise Rejection' export type AnalyticsPageViewModel = { session: string path: string + error?: { + error_message?: string + error_type?: AnalyticsErrorType + stack_trace?: string + } + expand?: { - session: AnalyticsSessionModel + session?: AnalyticsSessionModel } } & RecordModel @@ -36,7 +46,7 @@ export type AnalyticsErrorModel = { path?: string expand?: { - session: AnalyticsSessionModel + session?: AnalyticsSessionModel } } & RecordModel diff --git a/src/pages/admin/AdminRouter.tsx b/src/pages/admin/AdminRouter.tsx index 1770596..4174da1 100644 --- a/src/pages/admin/AdminRouter.tsx +++ b/src/pages/admin/AdminRouter.tsx @@ -1,5 +1,8 @@ import {usePB} from "@/lib/pocketbase.tsx"; import NotAllowed from "@/pages/error-pages/NotAllowed.tsx"; +import {Navigate, Outlet, Route, Routes} from "react-router-dom"; +import NotFound from "@/pages/error-pages/NotFound.tsx"; +import AnalyticsDashboard from "@/pages/admin/AnalyticsDashboard"; export default function AdminRouter() { @@ -9,10 +12,12 @@ export default function AdminRouter() { return } - return ( -
-

Admin Panel

-
- ) - + return <> + + }/> + }/> + }/> + + + } \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionRow.tsx b/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionRow.tsx new file mode 100644 index 0000000..876319b --- /dev/null +++ b/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionRow.tsx @@ -0,0 +1,202 @@ +import classes from "./index.module.css"; +import {ActionIcon, Code, Collapse, ThemeIcon, Tooltip} from "@mantine/core"; +import { + IconBrandAndroid, + IconBrandApple, + IconBrandChrome, + IconBrandEdge, + IconBrandFirefox, + IconBrandOpera, + IconBrandSafari, + IconBrandWindows, + IconBrowser, + IconBug, + IconCalendarClock, + IconCar, + IconClick, + IconDeviceGamepad2, + IconDeviceIpad, + IconDeviceLaptop, + IconDevices, + IconDevices2, + IconDeviceTv, + IconDeviceWatch, + IconEye, + IconEyeOff, + IconGlobe, + IconLanguage, + IconStereoGlasses +} from "@tabler/icons-react"; +import TextWithIcon from "@/components/layout/TextWithIcon"; + + +import {useDisclosure} from "@mantine/hooks"; +import ShowDebug from "@/components/ShowDebug.tsx"; +import {pprintDateTime} from "@/lib/datetime.ts"; +import {AnalyticsSessionModel} from "@/models/AnalyticsTypes.ts"; + +export const DeviceTypeIcon = ({deviceType}: { deviceType: string }) => { + if (deviceType === "mobile") { + return + } else if (deviceType === "tablet") { + return + } else if (deviceType === "desktop") { + return + } else if (deviceType === "console") { + return + } else if (deviceType === "smarttv") { + return + } else if (deviceType === "wearable") { + return + } else if (deviceType === "embedded") { + return + } else if (deviceType === "xr") { + return + } + + return +} + +export const BrowserIcon = ({browser}: { browser: string }) => { + browser = browser.toLocaleLowerCase().trim() + + if (browser.includes("safari")) { + return + } else if (browser.includes("firefox")) { + return + } else if (browser.includes("chrome")) { + return + } else if (browser.includes("edge")) { + return + } else if (browser.includes("opera")) { + return + } + + return +} + +export const OsIcon = ({os}: { os: string }) => { + os = os.toLocaleLowerCase().trim() + + if (os.includes("macos") || os.includes("ios")) { + return + } else if (os.includes("windows")) { + return + } else if (os.includes("android")) { + return + } else if (os.includes("chrome")) { + return + } + + return +} + +export default function AnalyticsSessionRow({session}: { + session: AnalyticsSessionModel, +}) { + + const [expanded, expandedHandler] = useDisclosure(false) + + const errorCount = session.expand?.analyticsPageViews_via_session?.filter(pv => pv.error).length + const pageViewCount = session.expand?.analyticsPageViews_via_session?.length + + return <> +
+
+ + + + }> + {pprintDateTime(session.created)} + +
+ +
+ + + + }> + {session.browser_name} {session.browser_version} + +
+ +
+ + + + }> + {session.operating_system} {session.operating_system_version} + +
+ +
+ + + + }> + {session.geo_country_code || "--"} + +
+ +
+ + + + }> + {session.preferred_language || "--"} + +
+ +
+ + + + }> + {pageViewCount ?? '--'} + +
+ +
+ + + + }> + {errorCount ?? '--'} + +
+ +
+ + + {expanded ? : } + + +
+
+ + + + Session Id - {session.id} +
+ created - {pprintDateTime(session.created)} +
+ updated - {pprintDateTime(session.updated)} +
+
+ IP - {session.ip_address} +
+ User Agent - {session.user_agent} +
+ Visitor Id - {session.visitor} +
+ {/**/} +
+ +} \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.module.css b/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.module.css new file mode 100644 index 0000000..0d0efdf --- /dev/null +++ b/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.module.css @@ -0,0 +1,58 @@ +.charts { + max-width: var(--max-content-width); + padding: var(--padding) 0; +} + +.mainGrid { + display: grid; + grid-template-columns: auto auto auto auto auto auto auto 0.1fr; + gap: var(--gap); +} + +.subgrid { + display: grid; + grid-template-columns: subgrid; + grid-column: span 8; + + background-color: var(--mantine-color-body); + border: var(--border); + border-radius: var(--border-radius); + padding: var(--mantine-spacing-sm); + font-size: var(--mantine-font-size-sm); + + @media (max-width: $mantine-breakpoint-sm) { + font-size: var(--mantine-font-size-sm); + display: flex; + flex-direction: column; + gap: var(--gap); + } + + &[data-error="true"] { + border-color: var(--mantine-color-error); + } +} + +.child { + display: flex; + justify-content: start; + align-items: center; + text-align: start; + gap: var(--gap); + + word-wrap: break-word; /* Ensures text wraps within the cell */ + overflow-wrap: break-word; /* Ensures text wraps within the cell */ + hyphens: auto; + @media (max-width: $mantine-breakpoint-sm) { + } +} + +.alignEnd { + @media (min-width: $mantine-breakpoint-sm) { + justify-content: end; + } +} + +.detailsContainer { + grid-column: span 8; /* Spans all columns of the main grid */ +} + diff --git a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.tsx b/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.tsx new file mode 100644 index 0000000..b305a51 --- /dev/null +++ b/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.tsx @@ -0,0 +1,197 @@ +import {useInfiniteQuery} from "@tanstack/react-query"; +import {usePB} from "@/lib/pocketbase.tsx"; +import dayjs from "dayjs"; +import {ActionIcon, Anchor, Button, Center, SimpleGrid, Switch, Text, TextInput, Tooltip} from "@mantine/core"; +import {IconArrowDown, IconBug, IconZoomReset} from "@tabler/icons-react"; +import {countByAllKeys} from "@/lib/datasience.ts"; +import {useMemo} from "react"; +import {RadarChart} from "@mantine/charts"; +import classes from "./index.module.css"; +import AnalyticsSessionRow from "@/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionRow.tsx"; +import {useForm} from "@mantine/form"; + +export default function AnalyticsSessions({lastNDays}: { lastNDays?: number }) { + + const {pb} = usePB() + + const formValues = useForm({ + mode: "uncontrolled", + initialValues: { + browser: '', + os: '', + country: '', + language: '', + hasErrors: false + } + }) + + const query = useInfiniteQuery({ + queryKey: ["sessions", {lastNDays}], + queryFn: async ({pageParam}) => { + const filter = ['visitor.meta.localDevMode!=true'] as string[] + + if (lastNDays !== undefined) { + filter.push(`created>='${dayjs().startOf("days").subtract(lastNDays, "days").toISOString()}'`) + } + + const filterValues = formValues.getValues() + + if (filterValues.browser) { + filter.push(`browser_name~'${filterValues.browser}'`) + } + if (filterValues.os) { + filter.push(`operating_system~'${filterValues.os}'`) + } + if (filterValues.country) { + filter.push(`geo_country_code~'${filterValues.country}'`) + } + if (filterValues.language) { + filter.push(`preferred_language~'${filterValues.language}'`) + } + if (filterValues.hasErrors) { + filter.push('analyticsPageViews_via_session.session=id&&analyticsPageViews_via_session.error?!=null') + } + + return await pb.collection("analyticsSessions").getList(pageParam, 500, { + filter: filter.join("&&"), + expand: 'analyticsErrors_via_session,analyticsPageViews_via_session', + sort: '-created' + }) + }, + getNextPageParam: (lastPage) => + lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1, + initialPageParam: 1, + }) + + const sessions = query.data?.pages.flatMap(p => p.items) ?? [] + + const sessionAnalytics = useMemo(() => { + return countByAllKeys(sessions) + }, [query.data]) + + return <> + + + + + + + + + + +
+ + Datenvisualisierung der letzten {sessions.length} Sessions ({query.data?.pages[0].totalItems} insgesamt) + + {query.hasNextPage && <> + {" • "} + query.fetchNextPage()} + > + Mehr laden + + + } + + +
+ +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ } + {...formValues.getInputProps("hasErrors", {type: "checkbox"})} + /> +
+ +
+ + query.refetch()} + disabled={query.isPending} + > + + + +
+
+ + {sessions.map(session => )} +
+ + {query.hasNextPage && ( +
+ +
+ )} +
+ +} \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/CountStat.tsx b/src/pages/admin/AnalyticsDashboard/CountStat.tsx new file mode 100644 index 0000000..5fc6109 --- /dev/null +++ b/src/pages/admin/AnalyticsDashboard/CountStat.tsx @@ -0,0 +1,115 @@ +import {Center, Group, RingProgress, Text} from "@mantine/core"; +import {useQuery} from "@tanstack/react-query"; +import {usePB} from "@/lib/pocketbase.tsx"; +import CountUp from "react-countup"; +import classes from "./index.module.css" +import {ReactNode, useMemo} from "react" +import dayjs from "dayjs"; + +export default function CountStat({lastNDays, collection, label, icon, filter}: { + collection: string + filter?: string + label: string + icon: ReactNode + lastNDays?: number +}) { + + const {pb} = usePB() + + const dataOverviewQuery = useQuery({ + queryKey: ['dataOverview', {collection}, {lastNDays}, {filter}], + queryFn: async () => { + // get oldest entry + const res = await pb.collection(collection).getList(1, 1, { + sort: 'created', + filter + }) + return { + oldestDate: dayjs(res.items[0].created).startOf("day") || dayjs(), + totalItems: res.totalItems + } + }, + // only run when startDate is set + enabled: lastNDays !== undefined + }) + + const countQuery = useQuery({ + queryKey: ['countStat', {collection}, {lastNDays}, {filter}], + queryFn: async () => { + + const _filter = [] as string[] + + if (filter) { + _filter.push(filter) + } + + if (lastNDays !== undefined) { + _filter.push(`updated>='${dayjs().startOf("days").subtract(lastNDays, "days").toISOString()}'`) + } + + const res = await pb.collection(collection).getList(1, 0, { + filter: _filter.join('&&') + }) + return res.totalItems + } + }) + + // when startDate is set, calculate the average using the dataOverviewQuery + const data = useMemo(() => { + if (!dataOverviewQuery.data) return { + expectedCount: 0, + count: countQuery.data ?? 0, + reachedPercentage: 0 + } + + const {oldestDate, totalItems} = dataOverviewQuery.data + const days = dayjs().diff(oldestDate, 'day') + + const dailyAverage = totalItems / days + + // calculate the expected count according to the average and the timespan since the startDate + const expectedCount = Math.round(dailyAverage * (lastNDays ?? 0)) + const count = countQuery.data ?? 0 + const reachedPercentage = (count / expectedCount) * 100 + + console.log({collection, days, dailyAverage, expectedCount, reachedPercentage}) + return { + expectedCount, + count, + reachedPercentage + } + }, [dataOverviewQuery.data, countQuery.data]) + + return ( +
+ + + {icon} + + } + /> + +
+ + {label} + + + + + + {!!lastNDays && <> + + erwartet (%) + + } +
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/index.module.css b/src/pages/admin/AnalyticsDashboard/index.module.css new file mode 100644 index 0000000..2104627 --- /dev/null +++ b/src/pages/admin/AnalyticsDashboard/index.module.css @@ -0,0 +1,10 @@ +.grid { + max-width: var(--max-content-width); +} + +.statsContainer { + border-radius: var(--border-radius); + border: var(--border); + padding: var(--padding); + background-color: var(--mantine-color-body); +} \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/index.tsx b/src/pages/admin/AnalyticsDashboard/index.tsx new file mode 100644 index 0000000..277abe0 --- /dev/null +++ b/src/pages/admin/AnalyticsDashboard/index.tsx @@ -0,0 +1,130 @@ +import {Anchor, Breadcrumbs, Button, Group, Select, SimpleGrid, Title} from "@mantine/core"; +import {Link, Navigate, NavLink, Outlet, Route, Routes} from "react-router-dom"; +import classes from './index.module.css' +import CountStat from "@/pages/admin/AnalyticsDashboard/CountStat.tsx"; +import {IconBug, IconCalendarStats, IconEye, IconTimeline, IconUsers, IconZoomExclamation} from "@tabler/icons-react"; +import {useMemo} from "react"; +import {useForm} from "@mantine/form"; +import NotFound from "@/pages/error-pages/NotFound.tsx"; +import Sessions from "@/pages/admin/AnalyticsDashboard/AnalyticsSessions"; + +export default function AnalyticsDashboard() { + + const dateSelect = useMemo(() => ([ + { + label: "letztes Jahr", + value: '365' + }, + { + label: "letzte 30 Tage", + value: '30' + }, + { + label: "letzte 7 Tage", + value: '7' + }, + { + label: "heute", + value: '1' + } + ]), []) + + const formValues = useForm({ + initialValues: { + dateFilter: undefined as string | undefined + } + }) + + const dateFilter = formValues.values.dateFilter ? parseInt(formValues.values.dateFilter) : undefined + + return <> +
+ {[ + {title: "Home", to: "/"}, + {title: "Admin Dashboard", to: "/admin"}, + ].map(({title, to}) => ( + + {title} + + ))} +
+
+ + + + Analytics + + + + { + [ + { + label: "Sessions", + path: "/admin/analytics/sessions", + icon: + }, + { + label: "Fehler", + path: "/admin/analytics/error", + icon: + } + ].map((e, i) => ( + + {({isActive}) => ( + + )} + + )) + } + +