From f1f6b600b0b7b8e689b6c05d936602b3269ad885 Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Wed, 13 Nov 2024 21:59:37 +0100 Subject: [PATCH] feat(analyticsDashboard): added trend view and error view --- config.ts | 2 +- package.json | 3 +- src/components/layout/nav/MenuItems.tsx | 11 + src/components/layout/nav/index.tsx | 142 +++++++------ src/components/users/modals/LoginModal.tsx | 2 +- src/lib/util.ts | 65 ++++++ src/main.tsx | 2 + src/models/AnalyticsTypes.ts | 53 +++++ src/models/index.ts | 11 +- .../AnalyticsErrors/AnalyticsErrorDetail.tsx | 35 +++ .../AnalyticsErrorRow.tsx} | 76 +++---- .../index.module.css | 6 +- .../AnalyticsErrors/index.tsx | 68 ++++++ .../AnalyticsGraphs/index.tsx | 75 +++++++ .../index.module.css | 6 +- .../AnalyticsPages/index.tsx | 160 ++++++++++++++ .../AnalyticsSessionDetail/index.tsx | 104 --------- .../AnalyticsSessions/index.tsx | 199 ------------------ .../AnalyticsTrend/index.tsx | 100 +++++++++ .../admin/AnalyticsDashboard/CountStat.tsx | 1 - .../admin/AnalyticsDashboard/index.module.css | 7 +- src/pages/admin/AnalyticsDashboard/index.tsx | 63 ++++-- .../CollectedAnalyticsData.tsx | 2 +- src/style/global.css | 12 ++ yarn.lock | 30 ++- 25 files changed, 779 insertions(+), 456 deletions(-) create mode 100644 src/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorDetail.tsx rename src/pages/admin/AnalyticsDashboard/{AnalyticsSessions/AnalyticsSessionRow.tsx => AnalyticsErrors/AnalyticsErrorRow.tsx} (68%) rename src/pages/admin/AnalyticsDashboard/{AnalyticsSessions => AnalyticsErrors}/index.module.css (87%) create mode 100644 src/pages/admin/AnalyticsDashboard/AnalyticsErrors/index.tsx create mode 100644 src/pages/admin/AnalyticsDashboard/AnalyticsGraphs/index.tsx rename src/pages/admin/AnalyticsDashboard/{AnalyticsSessions/AnalyticsSessionDetail => AnalyticsPages}/index.module.css (86%) create mode 100644 src/pages/admin/AnalyticsDashboard/AnalyticsPages/index.tsx delete mode 100644 src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionDetail/index.tsx delete mode 100644 src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.tsx create mode 100644 src/pages/admin/AnalyticsDashboard/AnalyticsTrend/index.tsx diff --git a/config.ts b/config.ts index b9d67eb..58f2fb4 100644 --- a/config.ts +++ b/config.ts @@ -10,7 +10,7 @@ export const PB_STORAGE_KEY = "stuve-it-login-record" // general export const APP_NAME = "StuVe IT" -export const APP_VERSION = "0.9.8 (beta)" +export const APP_VERSION = "0.9.89 (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" diff --git a/package.json b/package.json index dea6b95..ca4f59c 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,9 @@ "@mantine/hooks": "^7.10.0", "@mantine/modals": "^7.10.0", "@mantine/notifications": "^7.10.0", + "@mantine/nprogress": "^7.14.0", "@mantine/tiptap": "^7.10.0", - "@tabler/icons-react": "^3.2.0", + "@tabler/icons-react": "^3.21.0", "@tanstack/react-query": "^5.0.5", "@tanstack/react-query-devtools": "^5.31.0", "@tiptap/extension-collaboration": "^2.3.0", diff --git a/src/components/layout/nav/MenuItems.tsx b/src/components/layout/nav/MenuItems.tsx index c70760c..b9e9f0c 100644 --- a/src/components/layout/nav/MenuItems.tsx +++ b/src/components/layout/nav/MenuItems.tsx @@ -4,6 +4,7 @@ import {Fragment} from "react"; import { IconBug, IconConfetti, + IconDatabaseShare, IconDatabaseSmile, IconHome, IconList, @@ -11,6 +12,7 @@ import { IconQrcode } from "@tabler/icons-react"; import {usePB} from "@/lib/pocketbase.tsx"; +import {PB_BASE_URL} from "../../../../config.ts"; const NavItems = [ @@ -78,6 +80,14 @@ const AdminMenuItems = [ description: "Dashboard für Admins", link: "/admin", color: "blue" + }, + { + title: "Pocketbase", + icon: IconDatabaseShare, + description: "Pocketbase Datenbank", + link: `${PB_BASE_URL}/_`, + color: "brown", + "_blank": true } ] } @@ -108,6 +118,7 @@ export default function MenuItems() { component={NavLink} to={item.link} aria-label={item.description} + target={"_blank" in item ? "_blank" : undefined} color={'color' in item ? item.color as string : undefined} > {item.title} diff --git a/src/components/layout/nav/index.tsx b/src/components/layout/nav/index.tsx index ee85edb..661bc2a 100644 --- a/src/components/layout/nav/index.tsx +++ b/src/components/layout/nav/index.tsx @@ -6,6 +6,9 @@ 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"; +import {useIsFetching} from "@tanstack/react-query"; +import {NavigationProgress, nprogress} from "@mantine/nprogress"; +import {useEffect} from "react"; export default function NavBar() { @@ -16,74 +19,87 @@ export default function NavBar() { const {handler: userMenuHandler} = useUserMenu() const {handler: loginHandler} = useLogin() - return + + {user ? + <> + + + + + : ( + + + + )} + + + } \ No newline at end of file diff --git a/src/components/users/modals/LoginModal.tsx b/src/components/users/modals/LoginModal.tsx index 690f57e..3e91045 100644 --- a/src/components/users/modals/LoginModal.tsx +++ b/src/components/users/modals/LoginModal.tsx @@ -111,7 +111,7 @@ export default function LoginModal() { } placeholder={ formValues.values.authMethod === "ldap" ? - "vorname.nachname" : "Anmeldename" + "vorname.nachname" : "Anmeldename oder E-Mail" } {...formValues.getInputProps("username")} /> diff --git a/src/lib/util.ts b/src/lib/util.ts index 5b98514..326dc7b 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -77,6 +77,14 @@ export const useQRCodeScannerSettings = (): [QRCodeScannerSettings, (s: Partial< return [value, setValue] } +/** + * A reducer function that flattens nested arrays. + * + * @template T - The type of elements in the array. + * @param {T[]} acc - The accumulator array. + * @param {T | T[]} val - The current value, which can be an element or an array of elements. + * @returns {T[]} The flattened array. + */ export const flattenReducer = (acc: T[], val: T | T[]): T[] => { if (Array.isArray(val)) { return [...acc, ...val] @@ -84,6 +92,17 @@ export const flattenReducer = (acc: T[], val: T | T[]): T[] => { return [...acc, val] } +/** + * Maps the values of an object using a provided function. + * + * @template T - The type of the input object. + * @template T1 - The type of the keys in the input object. + * @template T2 - The type of the values in the input object. + * @template R - The type of the values in the resulting object. + * @param {T} obj - The input object to map. + * @param {(k: T1, v: T[T1], i: number) => R} fn - The function to apply to each key-value pair. + * @returns {Record} The resulting object with mapped values. + */ export const objectMap = ( obj: T, fn: (k: T1, v: T[T1], i: number) => R @@ -94,10 +113,56 @@ export const objectMap = ( + obj: T +): Array<[key: keyof T, value: T[keyof T], index: number]> => { + return Object.keys(obj).map((key, index) => [ + key as keyof T, + obj[key as keyof T], + index + ]); +} + /** * This functions filters out all duplicate values from an array. * @example [1, 2, 3, 1, 2, 4].filter(onlyUnique) // [1, 2, 3, 4] */ export function onlyUnique(value: T, index: number, array: T[]) { return array.indexOf(value) === index; +} + +/** + * Converts a string to title case and replaces all underscores with spaces. + * Each word's first letter is capitalized, and all underscores are removed. + * + * @param str - The input string + * @returns A new string in title case with underscores replaced by spaces + * + * @example + * const result = toTitleCase("hello_world_this_is_test"); + * // Result: "Hello World This Is Test" + */ +export function toTitleCase(str: string): string { + return str + .toLowerCase() // Convert the string to lowercase + .replace(/\b\w/g, char => char.toUpperCase()); // Capitalize each word's first letter } \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 620f7a8..0d8c29d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,7 @@ import '@mantine/dates/styles.layer.css'; import '@mantine/tiptap/styles.layer.css'; import '@mantine/notifications/styles.layer.css'; import '@mantine/charts/styles.css'; +import '@mantine/nprogress/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"; @@ -28,6 +29,7 @@ const themeOverride = createTheme({ headings: { fontFamily: 'Overpass, sans-serif' }, + defaultRadius: 'sm', components: { Alert: Alert.extend({ defaultProps: { diff --git a/src/models/AnalyticsTypes.ts b/src/models/AnalyticsTypes.ts index 7d76970..f23a235 100644 --- a/src/models/AnalyticsTypes.ts +++ b/src/models/AnalyticsTypes.ts @@ -37,6 +37,28 @@ export type AnalyticsPageViewModel = { } } & RecordModel +export type AnalyticsPageViewsWithSessionDetailModel = { + path_error_count: number +} & AnalyticsPageViewModel & Pick + +export type AnalyticsDailyAggregateAPIResponse = { + date: string; // Format: ISO 8601 date string + error_count: number; + page_view_count: number; + unique_visitor_count: number; +}[] + export type AnalyticsIpApiResult = { ip: string country_code: string @@ -44,4 +66,35 @@ export type AnalyticsIpApiResult = { asn: string as_desc: string user_agent: string +} + +interface Data { + value: string; + count: number; +} + +export interface AggregateCountApiResponse { + device_type: Data[]; + browser_name: Data[]; + operating_system: Data[]; + geo_country_code: Data[]; + preferred_language: Data[]; +} + +export type PageViewCountItem = { + id: number; + path: string; + count: number; + last_30_days_data: { + date: string; // ISO 8601 formatted date + count: number; + }[] +} + +export interface PageViewCountResponse { + page: number, + perPage: number, + totalItems: number, + totalPages: number, + items: PageViewCountItem[] } \ No newline at end of file diff --git a/src/models/index.ts b/src/models/index.ts index cc416d2..1cea2f5 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -10,7 +10,12 @@ import { } from "./EventTypes.ts"; import {MessagesModel} from "@/models/MessageTypes.ts" import {EmailModel} from "@/models/EmailTypes.ts" -import {AnalyticsPageViewModel, AnalyticsSessionModel, AnalyticsVisitorsModel} from "@/models/AnalyticsTypes.ts" +import { + AnalyticsPageViewModel, + AnalyticsPageViewsWithSessionDetailModel, + AnalyticsSessionModel, + AnalyticsVisitorsModel +} from "@/models/AnalyticsTypes.ts" export type SettingsModel = { key: ['privacyPolicy', 'agb', 'stexGroup', 'stuveEventQuestions'] @@ -60,4 +65,6 @@ export interface TypedPocketBase extends PocketBase { collection(idOrName: 'analyticsSessions'): RecordService collection(idOrName: 'analyticsPageViews'): RecordService -} \ No newline at end of file + + collection(idOrName: 'analyticsPageViewsWithSessionDetail'): RecordService +} diff --git a/src/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorDetail.tsx b/src/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorDetail.tsx new file mode 100644 index 0000000..9c9fa0b --- /dev/null +++ b/src/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorDetail.tsx @@ -0,0 +1,35 @@ +import {AnalyticsPageViewsWithSessionDetailModel} from "@/models/AnalyticsTypes.ts"; +import {Alert, Code} from "@mantine/core"; +import {IconDeviceLaptop, IconGraph, IconHash, IconInfoCircle, IconStack3, IconUser} from "@tabler/icons-react"; +import {CodeHighlight} from "@mantine/code-highlight"; +import TextWithIcon from "@/components/layout/TextWithIcon"; + +export default function AnalyticsErrorDetail({pageView}: { pageView: AnalyticsPageViewsWithSessionDetailModel }) { + return
+ } title={"Informationen"}> +
+ }> + Visitor Id {pageView.visitor} + + + }> + Session Id {pageView.id} + + + }> + Fehler-Anzahl für Pfad {pageView.path_error_count} + + + }> + User Agent {pageView.user_agent} + +
+
+ } title={" Stack Trace"} color={"red"}> +
+ {pageView.error?.error_type} + +
+
+
+} \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionRow.tsx b/src/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorRow.tsx similarity index 68% rename from src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionRow.tsx rename to src/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorRow.tsx index 93c3569..4830d7f 100644 --- a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionRow.tsx +++ b/src/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorRow.tsx @@ -13,7 +13,6 @@ import { IconBug, IconCalendarClock, IconCar, - IconClick, IconDeviceGamepad2, IconDeviceIpad, IconDeviceLaptop, @@ -23,8 +22,7 @@ import { IconDeviceWatch, IconEye, IconEyeOff, - IconGlobe, - IconLanguage, + IconNotebook, IconStereoGlasses } from "@tabler/icons-react"; import TextWithIcon from "@/components/layout/TextWithIcon"; @@ -32,8 +30,9 @@ import TextWithIcon from "@/components/layout/TextWithIcon"; import {useDisclosure} from "@mantine/hooks"; import {pprintDateTime} from "@/lib/datetime.ts"; -import {AnalyticsSessionModel} from "@/models/AnalyticsTypes.ts"; -import AnalyticsSessionDetail from "@/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionDetail"; +import {AnalyticsPageViewsWithSessionDetailModel} from "@/models/AnalyticsTypes.ts"; +import AnalyticsErrorDetail from "@/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorDetail.tsx"; +import {Link} from "react-router-dom"; export const DeviceTypeIcon = ({deviceType}: { deviceType: string }) => { if (deviceType === "mobile") { @@ -91,64 +90,43 @@ export const OsIcon = ({os}: { os: string }) => { return } -export default function AnalyticsSessionRow({session}: { - session: AnalyticsSessionModel, +export default function AnalyticsErrorRow({pageView}: { + pageView: AnalyticsPageViewsWithSessionDetailModel, }) { 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 ?? '--'} + {pprintDateTime(pageView.created)} } + themeIconProps={{color: "red"}} > - {errorCount ?? '--'} + {pageView.error?.error_type || ""} + + + } + > + + {pageView.path} + + + + } + > + {pageView.browser_name} {pageView.browser_version}
@@ -161,7 +139,7 @@ export default function AnalyticsSessionRow({session}: {
- + } \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.module.css b/src/pages/admin/AnalyticsDashboard/AnalyticsErrors/index.module.css similarity index 87% rename from src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.module.css rename to src/pages/admin/AnalyticsDashboard/AnalyticsErrors/index.module.css index 0d0efdf..bd2689b 100644 --- a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.module.css +++ b/src/pages/admin/AnalyticsDashboard/AnalyticsErrors/index.module.css @@ -5,14 +5,14 @@ .mainGrid { display: grid; - grid-template-columns: auto auto auto auto auto auto auto 0.1fr; + grid-template-columns: auto auto auto auto 0.1fr; gap: var(--gap); } .subgrid { display: grid; grid-template-columns: subgrid; - grid-column: span 8; + grid-column: span 5; background-color: var(--mantine-color-body); border: var(--border); @@ -53,6 +53,6 @@ } .detailsContainer { - grid-column: span 8; /* Spans all columns of the main grid */ + grid-column: span 5; /* Spans all columns of the main grid */ } diff --git a/src/pages/admin/AnalyticsDashboard/AnalyticsErrors/index.tsx b/src/pages/admin/AnalyticsDashboard/AnalyticsErrors/index.tsx new file mode 100644 index 0000000..e727c27 --- /dev/null +++ b/src/pages/admin/AnalyticsDashboard/AnalyticsErrors/index.tsx @@ -0,0 +1,68 @@ +import {useInfiniteQuery} from "@tanstack/react-query"; +import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; +import dayjs from "dayjs"; +import {Button, Center, Loader} from "@mantine/core"; +import {IconArrowDown} from "@tabler/icons-react"; +import classes from "./index.module.css"; +import AnalyticsErrorRow from "@/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorRow.tsx"; + +export default function AnalyticsErrors({lastNDays}: { lastNDays: number }) { + + const {pb} = usePB() + + const query = useInfiniteQuery({ + queryKey: ["sessions", {lastNDays}], + queryFn: async ({pageParam}) => { + const filter = ['visitor.meta.localDevMode!=true'] as string[] + + filter.push(`created>='${dayjs().startOf("days").subtract(lastNDays, "days").toISOString()}'`) + + filter.push(`error!=null`) + + return await pb.collection("analyticsPageViewsWithSessionDetail").getList(pageParam, 100, { + filter: filter.join("&&"), + sort: '-created' + }) + }, + getNextPageParam: (lastPage) => + lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1, + initialPageParam: 1, + }) + + const pageViews = query.data?.pages.flatMap(p => p.items) ?? [] + + if (query.error) return ( +
+ +
+ ) + + if (!query.data) return ( +
+ +
+ ) + + return <> +
+
+ {pageViews.map(pageView => )} +
+ + {query.hasNextPage && ( +
+ +
+ )} +
+ +} \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/AnalyticsGraphs/index.tsx b/src/pages/admin/AnalyticsDashboard/AnalyticsGraphs/index.tsx new file mode 100644 index 0000000..faa2f93 --- /dev/null +++ b/src/pages/admin/AnalyticsDashboard/AnalyticsGraphs/index.tsx @@ -0,0 +1,75 @@ +import {Center, Loader, SimpleGrid, Stack, Title} from "@mantine/core"; +import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; +import {useQuery} from "@tanstack/react-query"; +import {AggregateCountApiResponse} from "@/models/AnalyticsTypes.ts"; +import {BarChart} from "@mantine/charts"; +import dayjs from "dayjs"; +import {objectIterate, toTitleCase} from "@/lib/util.ts"; +import classes from "../index.module.css"; + +function Graph({ + title, + data + }: { + title: string, + data: object[], +}) { + return + + {title} + + + +} + +export default function AnalyticsGraphs({lastNDays}: { lastNDays: number }) { + + const {pb} = usePB() + + const query = useQuery({ + queryKey: ["analyticsSessionCounts", {lastNDays}], + queryFn: async () => { + return await pb.send("/api/analytics/aggregateCount", { + method: "GET", + headers: { + "Content-Type": "application/json" + }, + query: { + startDate: dayjs().subtract(lastNDays, "days").toISOString() + }, + requestKey: null, + }) + } + }) + + if (query.error) return ( +
+ +
+ ) + + if (!query.data) return ( +
+ +
+ ) + + return ( + + {query.data && objectIterate(query.data).map(([key, values, index]) => ( + + ))} + + ) +} \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionDetail/index.module.css b/src/pages/admin/AnalyticsDashboard/AnalyticsPages/index.module.css similarity index 86% rename from src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionDetail/index.module.css rename to src/pages/admin/AnalyticsDashboard/AnalyticsPages/index.module.css index 0ee4fc2..b47776b 100644 --- a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionDetail/index.module.css +++ b/src/pages/admin/AnalyticsDashboard/AnalyticsPages/index.module.css @@ -1,6 +1,6 @@ .mainGrid { display: grid; - grid-template-columns: auto auto auto auto auto auto; + grid-template-columns: auto auto auto; background-color: var(--mantine-color-body); border: var(--border); border-radius: var(--border-radius); @@ -9,7 +9,7 @@ .subGrid { display: grid; grid-template-columns: subgrid; - grid-column: span 6; + grid-column: span 3; gap: var(--gap); font-size: var(--mantine-font-size-sm); @@ -44,5 +44,5 @@ } .debugContainer { - grid-column: span 6; /* Spans all columns of the main grid */ + grid-column: span 3; /* Spans all columns of the main grid */ } \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/AnalyticsPages/index.tsx b/src/pages/admin/AnalyticsDashboard/AnalyticsPages/index.tsx new file mode 100644 index 0000000..4b3ef91 --- /dev/null +++ b/src/pages/admin/AnalyticsDashboard/AnalyticsPages/index.tsx @@ -0,0 +1,160 @@ +import {useInfiniteQuery} from "@tanstack/react-query"; +import dayjs from "dayjs"; +import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; +import {Button, Center, Loader, Tooltip} from "@mantine/core"; + +import classes from "./index.module.css" +import {PageViewCountItem, PageViewCountResponse} from "@/models/AnalyticsTypes.ts"; +import {IconArrowDown, IconEye, IconNotebook} from "@tabler/icons-react"; +import TextWithIcon from "@/components/layout/TextWithIcon"; +import {Sparkline} from "@mantine/charts"; +import {Link} from "react-router-dom"; + +interface DailyCount { + date: string; // Format: 'YYYY-MM-DD' + count: number; +} + +/** + * Fills an array with daily counts for the last 30 days, adding missing days with a count of 0. + * + * @param data - An array of objects representing days with counts, in the format: + * [{ date: 'YYYY-MM-DD', count: number }, ...] + * @returns A new array with exactly 30 entries, each day from 29 days ago to today. + */ +const fillLast30Days = (data: DailyCount[]): DailyCount[] => { + // Initialize the result array with the last 30 days, each day with a count of 0 + const last30Days: DailyCount[] = Array.from({length: 30}, (_, i) => ({ + date: dayjs().subtract(29 - i, 'day').format('YYYY-MM-DD'), + count: 0 + })) + + // Convert input data into a map for quick lookup + const dataMap = new Map(data.map(item => [item.date, item.count])) + + // Fill in counts from the input data, if available + return last30Days.map(day => ({ + date: day.date, + count: dataMap.get(day.date) ?? 0 + })) +} + +/** + * Calculates the trend of the counts over the last 30 days. + * + * @param data - An array of daily counts for the last 30 days. + * @returns A string indicating the trend ('rising', 'falling', 'stable'). + */ +const calculateTrend = (data: DailyCount[]): string => { + let totalChange = 0 + for (let i = 1; i < data.length; i++) { + totalChange += data[i].count - data[i - 1].count + } + + const averageChange = totalChange / (data.length - 1) + return averageChange > 0 ? 'rising' : averageChange < 0 ? 'falling' : 'stable' +}; + + +const PageCountRow = ({pageView}: { pageView: PageViewCountItem }) => { + + const data = fillLast30Days(pageView.last_30_days_data) + const trend = calculateTrend(data) + + return ( +
+ } + > + {pageView.count} + + + } + > + + {pageView.path} + + + + { + data.length > 1 && ( + +
+ d.count)} + curveType="linear" + color={trend === "stable" ? "blue" : trend === "rising" ? "green" : "orange"} + fillOpacity={0.6} + strokeWidth={2} + /> +
+
+ )} +
+ ) +} + +export default function AnalyticsPages({lastNDays}: { lastNDays: number }) { + + const {pb} = usePB() + + const query = useInfiniteQuery({ + queryKey: ["pageCounts", {lastNDays}], + queryFn: async ({pageParam}) => { + return await pb.send("/api/analytics/pageViewCount", { + query: { + page: pageParam, + perPage: 100, + startDate: dayjs().startOf("days").subtract(lastNDays, "days").toISOString(), + sort: 'DESC' + } + }) + }, + getNextPageParam: (lastPage) => + lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1, + initialPageParam: 1, + }) + + const pageView = query.data?.pages.flatMap(p => p.items) ?? [] + + if (query.error) return ( +
+ +
+ ) + + if (!query.data) return ( +
+ +
+ ) + + return <> +
+
+ {pageView.map((pageView, index) => ( + + ))} +
+
+ {query.hasNextPage && ( +
+ +
+ )} + +} \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionDetail/index.tsx b/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionDetail/index.tsx deleted file mode 100644 index 513f748..0000000 --- a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionDetail/index.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import {AnalyticsPageViewModel, AnalyticsSessionModel} from "@/models/AnalyticsTypes.ts"; -import classes from "./index.module.css"; -import {formatDuration, pprintDateTime, pprintTime} from "@/lib/datetime.ts"; -import ShowDebug from "@/components/ShowDebug.tsx"; -import {Code} from "@mantine/core"; -import TextWithIcon from "@/components/layout/TextWithIcon"; -import {IconBug, IconClick, IconClock, IconHourglass, IconInfoCircle, IconStack3Filled} from "@tabler/icons-react"; - -export default function AnalyticsSessionDetail({session}: { session: AnalyticsSessionModel }) { - return
- - 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} -
- { - session.expand?.analyticsPageViews_via_session?.sort((a, b) => { - return a.created < b.created ? -1 : 1 - }).map((pageView, index, array) => { - return ( - - ) - }) - - } -
-} - -function PageViewRow({pageView, nextPageView}: { - pageView: AnalyticsPageViewModel, - nextPageView?: AnalyticsPageViewModel -}) { - return ( -
- } - > - {pprintTime(pageView.created)} - - - } - > - {nextPageView ? - formatDuration(pageView.created, nextPageView.created, true) - : - "End" - } - - - } - > - {pageView.path} - - - { - pageView.error && <> - } - > - {pageView.error.error_type} - - - } - > - {pageView.error.error_message} - - - } - > - - {pageView.error.stack_trace} - - - - } -
- ) -} \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.tsx b/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.tsx deleted file mode 100644 index 054537f..0000000 --- a/src/pages/admin/AnalyticsDashboard/AnalyticsSessions/index.tsx +++ /dev/null @@ -1,199 +0,0 @@ -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, 100, { - 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) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query.data]) - - return <> - - - - - - - - - - -
- - Datenvisualisierung der neuesten {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/AnalyticsTrend/index.tsx b/src/pages/admin/AnalyticsDashboard/AnalyticsTrend/index.tsx new file mode 100644 index 0000000..ddd9ece --- /dev/null +++ b/src/pages/admin/AnalyticsDashboard/AnalyticsTrend/index.tsx @@ -0,0 +1,100 @@ +import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; +import {useQuery} from "@tanstack/react-query"; +import {AnalyticsDailyAggregateAPIResponse} from "@/models/AnalyticsTypes.ts"; +import dayjs from "dayjs"; +import {Center, Loader} from "@mantine/core"; +import {useMemo} from "react"; +import {CompositeChart} from "@mantine/charts"; +import {pprintDate} from "@/lib/datetime.ts"; + +interface DailyCount { + date: string; // Format: ISO 8601 date string + error_count: number; + page_view_count: number; + unique_visitor_count: number; +} + +/** + * Fills an array with daily counts for the lastNDays, adding missing days with a count of 0. + * + * @param data - An array of objects representing days with counts, in the format: + * @param lastNDays - The number of days to fill in the array. + * @returns A new array with exactly lastNDays entries, each day from lastNDays-1 days ago to today. + */ +const fillLastNDays = (data: DailyCount[], lastNDays: number): DailyCount[] => { + // Convert input data into a map for fast lookup + const dataMap = new Map(data.map(item => [item.date, item])); + + // Initialize the result array with the last N days, filling in counts from dataMap if available + return Array.from({length: lastNDays}, (_, i) => { + const date = dayjs().subtract(lastNDays - 1 - i, 'day').format('YYYY-MM-DD'); + const entry = dataMap.get(date); + + return { + date, + error_count: entry?.error_count ?? 0, + page_view_count: entry?.page_view_count ?? 0, + unique_visitor_count: entry?.unique_visitor_count ?? 0 + }; + }); +} + +export default function AnalyticsTrend({lastNDays}: { lastNDays: number }) { + + const {pb} = usePB() + + const query = useQuery({ + queryKey: ["analyticsDailyAggregates", {lastNDays}], + queryFn: async () => { + return await pb.send("/api/analytics/dailyAggregates", { + method: "GET", + headers: { + "Content-Type": "application/json" + }, + query: { + startDate: dayjs().subtract(lastNDays, "days").toISOString() + }, + requestKey: null, + }) + } + }) + + const data = useMemo(() => + fillLastNDays(query.data ?? [], lastNDays).map(item => ({ + ...item, + date: pprintDate(item.date) + })) + , [lastNDays, query.data]) + + if (query.error) return ( +
+ +
+ ) + + if (!query.data) return ( +
+ +
+ ) + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/src/pages/admin/AnalyticsDashboard/CountStat.tsx b/src/pages/admin/AnalyticsDashboard/CountStat.tsx index 5fc6109..ca11f8e 100644 --- a/src/pages/admin/AnalyticsDashboard/CountStat.tsx +++ b/src/pages/admin/AnalyticsDashboard/CountStat.tsx @@ -72,7 +72,6 @@ export default function CountStat({lastNDays, collection, label, icon, filter}: const count = countQuery.data ?? 0 const reachedPercentage = (count / expectedCount) * 100 - console.log({collection, days, dailyAverage, expectedCount, reachedPercentage}) return { expectedCount, count, diff --git a/src/pages/admin/AnalyticsDashboard/index.module.css b/src/pages/admin/AnalyticsDashboard/index.module.css index 2104627..bcf731c 100644 --- a/src/pages/admin/AnalyticsDashboard/index.module.css +++ b/src/pages/admin/AnalyticsDashboard/index.module.css @@ -7,4 +7,9 @@ border: var(--border); padding: var(--padding); background-color: var(--mantine-color-body); -} \ No newline at end of file +} + +.graphsGrid { + max-width: var(--max-content-width); + padding: var(--padding) 0; +} diff --git a/src/pages/admin/AnalyticsDashboard/index.tsx b/src/pages/admin/AnalyticsDashboard/index.tsx index 277abe0..9c2f5e8 100644 --- a/src/pages/admin/AnalyticsDashboard/index.tsx +++ b/src/pages/admin/AnalyticsDashboard/index.tsx @@ -1,19 +1,34 @@ import {Anchor, Breadcrumbs, Button, Group, Select, SimpleGrid, Title} from "@mantine/core"; -import {Link, Navigate, NavLink, Outlet, Route, Routes} from "react-router-dom"; +import {Link, 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 { + IconBug, + IconCalendarStats, + IconChartPie4, + IconEye, + IconGraph, + IconTimeline, + IconUsers, + IconZoomExclamation +} from "@tabler/icons-react"; import {useForm} from "@mantine/form"; +import AnalyticsGraphs from "@/pages/admin/AnalyticsDashboard/AnalyticsGraphs"; import NotFound from "@/pages/error-pages/NotFound.tsx"; -import Sessions from "@/pages/admin/AnalyticsDashboard/AnalyticsSessions"; +import AnalyticsPages from "@/pages/admin/AnalyticsDashboard/AnalyticsPages"; +import AnalyticsErrors from "@/pages/admin/AnalyticsDashboard/AnalyticsErrors"; +import AnalyticsTrend from "@/pages/admin/AnalyticsDashboard/AnalyticsTrend"; export default function AnalyticsDashboard() { - const dateSelect = useMemo(() => ([ + const dateSelect = [ { label: "letztes Jahr", - value: '365' + value: '360' + }, + { + label: "letztes halbes Jahr", + value: '180' }, { label: "letzte 30 Tage", @@ -27,15 +42,15 @@ export default function AnalyticsDashboard() { label: "heute", value: '1' } - ]), []) + ] const formValues = useForm({ initialValues: { - dateFilter: undefined as string | undefined + dateFilter: dateSelect[1].value // default to last 30 days } }) - const dateFilter = formValues.values.dateFilter ? parseInt(formValues.values.dateFilter) : undefined + const dateFilter = parseInt(formValues.values.dateFilter) return <>
@@ -59,17 +74,27 @@ export default function AnalyticsDashboard() { { [ { - label: "Sessions", - path: "/admin/analytics/sessions", + label: "Trend", + path: "/admin/analytics", + icon: + }, + { + label: "Seiten", + path: "/admin/analytics/pages", icon: }, + { + label: "Graphen", + path: "/admin/analytics/graphs", + icon: + }, { label: "Fehler", path: "/admin/analytics/error", icon: } ].map((e, i) => ( - + {({isActive}) => (