From da8c7e99c537694e828d77410d903698864eaf6d Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 13 Jun 2025 21:13:11 -0700 Subject: [PATCH] Unified loading states. --- .../websites/[websiteId]/WebsiteChart.tsx | 27 ++-- .../[websiteId]/WebsiteDetailsPage.tsx | 5 +- .../[websiteId]/WebsiteMetricsBar.tsx | 75 +++++++----- .../[websiteId]/events/EventsMetricsBar.tsx | 49 ++++---- .../[websiteId]/realtime/RealtimeHeader.tsx | 2 +- .../reports/attribution/Attribution.tsx | 5 +- .../reports/breakdown/Breakdown.tsx | 2 +- .../[websiteId]/reports/funnels/Funnel.tsx | 2 +- .../reports/funnels/FunnelsPage.tsx | 4 +- .../[websiteId]/reports/goals/Goal.tsx | 2 +- .../[websiteId]/reports/goals/GoalsPage.tsx | 4 +- .../[websiteId]/reports/journeys/Journey.tsx | 2 +- .../reports/retention/Retention.tsx | 17 +-- .../[websiteId]/reports/revenue/Revenue.tsx | 10 +- .../websites/[websiteId]/reports/utm/UTM.tsx | 5 +- .../sessions/SessionsMetricsBar.tsx | 49 ++++---- .../[websiteId]/sessions/SessionsWeekly.tsx | 115 +++++++++--------- .../sessions/[sessionId]/SessionActivity.tsx | 45 +++++-- .../sessions/[sessionId]/SessionData.tsx | 3 +- .../[sessionId]/SessionDetailsPage.tsx | 3 +- .../sessions/[sessionId]/SessionStats.tsx | 2 +- src/app/Providers.tsx | 1 + .../[websiteId]/event-data/[eventId]/route.ts | 25 ++++ .../api/websites/[websiteId]/stats/route.ts | 12 +- src/app/sso/SSOPage.tsx | 2 +- src/components/common/DataGrid.tsx | 16 +-- src/components/common/LoadingPanel.tsx | 57 ++++++--- src/components/hooks/index.ts | 2 +- .../hooks/queries/useActiveUsersQuery.ts | 7 +- .../hooks/queries/useEventDataEventsQuery.ts | 7 +- .../queries/useEventDataPropertiesQuery.ts | 7 +- .../hooks/queries/useEventDataQuery.ts | 19 +++ .../hooks/queries/useEventDataValuesQuery.ts | 4 +- src/components/hooks/queries/useLoginQuery.ts | 3 +- .../hooks/queries/useReportsQuery.ts | 7 +- .../hooks/queries/useResultQuery.ts | 4 +- .../queries/useSessionDataPropertiesQuery.ts | 7 +- .../queries/useSessionDataValuesQuery.ts | 4 +- src/components/hooks/queries/useUTMQuery.ts | 20 --- .../hooks/queries/useWebsiteEventsQuery.ts | 7 +- .../queries/useWebsiteEventsSeriesQuery.ts | 7 +- .../hooks/queries/useWebsiteMetricsQuery.ts | 24 ++-- .../hooks/queries/useWebsitePageviewsQuery.ts | 20 +-- .../hooks/queries/useWebsiteStatsQuery.ts | 20 ++- src/components/input/DateFilter.tsx | 4 +- src/components/metrics/EventData.tsx | 22 ++++ src/components/metrics/LanguagesTable.tsx | 7 +- src/components/metrics/MetricsBar.tsx | 20 +-- src/components/metrics/MetricsTable.tsx | 45 ++++--- src/lib/types.ts | 3 + src/queries/sql/events/getEventData.ts | 57 +++++++++ src/queries/sql/getWebsiteStats.ts | 2 +- 52 files changed, 506 insertions(+), 364 deletions(-) create mode 100644 src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts create mode 100644 src/components/hooks/queries/useEventDataQuery.ts delete mode 100644 src/components/hooks/queries/useUTMQuery.ts create mode 100644 src/components/metrics/EventData.tsx create mode 100644 src/queries/sql/events/getEventData.ts diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx index a20c1e48..9a6338cc 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx @@ -3,6 +3,7 @@ import { LoadingPanel } from '@/components/common/LoadingPanel'; import { PageviewsChart } from '@/components/metrics/PageviewsChart'; import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery'; import { useDateRange } from '@/components/hooks'; +import { Panel } from '@/components/common/Panel'; export function WebsiteChart({ websiteId, @@ -13,10 +14,10 @@ export function WebsiteChart({ }) { const { dateRange, dateCompare } = useDateRange(websiteId); const { startDate, endDate, unit, value } = dateRange; - const { data, isLoading, error } = useWebsitePageviewsQuery( + const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({ websiteId, - compareMode ? dateCompare : undefined, - ); + compareMode: compareMode ? dateCompare : undefined, + }); const { pageviews, sessions, compare } = (data || {}) as any; const chartData = useMemo(() => { @@ -47,14 +48,16 @@ export function WebsiteChart({ }, [data, startDate, endDate, unit]); return ( - - - + + + + + ); } diff --git a/src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx b/src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx index 2e146ec0..87b3aca2 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx @@ -1,6 +1,5 @@ 'use client'; import { Column } from '@umami/react-zen'; -import { Panel } from '@/components/common/Panel'; import { useNavigation } from '@/components/hooks'; import { WebsiteChart } from './WebsiteChart'; import { WebsiteExpandedView } from './WebsiteExpandedView'; @@ -18,9 +17,7 @@ export function WebsiteDetailsPage({ websiteId }: { websiteId: string }) { - - - + {!view && !compare && } {view && !compare && } {compare && } diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx index 1672b8a0..d147b9b3 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx @@ -4,6 +4,7 @@ import { MetricsBar } from '@/components/metrics/MetricsBar'; import { formatShortTime, formatLongNumber } from '@/lib/format'; import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery'; import { useWebsites } from '@/store/websites'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; export function WebsiteMetricsBar({ websiteId, @@ -18,72 +19,80 @@ export function WebsiteMetricsBar({ const { dateRange } = useDateRange(websiteId); const { formatMessage, labels } = useMessages(); const dateCompare = useWebsites(state => state[websiteId]?.dateCompare); - const { data, isLoading, isFetched, error } = useWebsiteStatsQuery( + const { data, isLoading, isFetching, error } = useWebsiteStatsQuery( websiteId, compareMode && dateCompare, ); const isAllTime = dateRange.value === 'all'; - const { pageviews, visitors, visits, bounces, totaltime } = data || {}; + const { pageviews, visitors, visits, bounces, totaltime, previous } = data || {}; const metrics = data ? [ { - ...pageviews, + value: pageviews, label: formatMessage(labels.views), - change: pageviews.value - pageviews.prev, + change: pageviews - previous.pageviews, formatValue: formatLongNumber, }, { - ...visits, + value: visits, label: formatMessage(labels.visits), - change: visits.value - visits.prev, + change: visits - previous.visits, formatValue: formatLongNumber, }, { - ...visitors, + value: visitors, label: formatMessage(labels.visitors), - change: visitors.value - visitors.prev, + change: visitors - previous.visitors, formatValue: formatLongNumber, }, { label: formatMessage(labels.bounceRate), - value: (Math.min(visits.value, bounces.value) / visits.value) * 100, - prev: (Math.min(visits.prev, bounces.prev) / visits.prev) * 100, + value: (Math.min(visits, bounces) / visits) * 100, + prev: (Math.min(previous.visits, previous.bounces) / previous.visits) * 100, change: - (Math.min(visits.value, bounces.value) / visits.value) * 100 - - (Math.min(visits.prev, bounces.prev) / visits.prev) * 100, + (Math.min(visits, bounces) / visits) * 100 - + (Math.min(previous.visits, previous.bounces) / previous.visits) * 100, formatValue: n => Math.round(+n) + '%', reverseColors: true, }, { label: formatMessage(labels.visitDuration), - value: totaltime.value / visits.value, - prev: totaltime.prev / visits.prev, - change: totaltime.value / visits.value - totaltime.prev / visits.prev, + value: totaltime / visits, + prev: previous.totaltime / previous.visits, + change: totaltime / visits - previous.totaltime / previous.visits, formatValue: n => `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`, }, ] - : []; + : null; return ( - - {metrics.map(({ label, value, prev, change, formatValue, reverseColors }) => { - return ( - - ); - })} - + + + {metrics?.map(({ label, value, prev, change, formatValue, reverseColors }) => { + return ( + + ); + })} + + ); } diff --git a/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx index 26fdfa5d..5200ab16 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx @@ -3,33 +3,36 @@ import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsi import { MetricCard } from '@/components/metrics/MetricCard'; import { MetricsBar } from '@/components/metrics/MetricsBar'; import { formatLongNumber } from '@/lib/format'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; export function EventsMetricsBar({ websiteId }: { websiteId: string }) { const { formatMessage, labels } = useMessages(); - const { data, isLoading, isFetched, error } = useWebsiteSessionStatsQuery(websiteId); + const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId); return ( - - - - - - + + + + + + + + ); } diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx index 20089bd3..7f4df048 100644 --- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx @@ -8,7 +8,7 @@ export function RealtimeHeader({ data }: { data: RealtimeData }) { const { totals }: any = data || {}; return ( - + diff --git a/src/app/(main)/websites/[websiteId]/reports/attribution/Attribution.tsx b/src/app/(main)/websites/[websiteId]/reports/attribution/Attribution.tsx index cd3108b0..ae56329a 100644 --- a/src/app/(main)/websites/[websiteId]/reports/attribution/Attribution.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/attribution/Attribution.tsx @@ -40,7 +40,6 @@ export function Attribution({ step, }, }); - const isEmpty = !Object.keys(data || {}).length; const { formatMessage, labels } = useMessages(); @@ -83,9 +82,9 @@ export function Attribution({ } return ( - + - + {metrics?.map(({ label, value, formatValue }) => { return ; })} diff --git a/src/app/(main)/websites/[websiteId]/reports/breakdown/Breakdown.tsx b/src/app/(main)/websites/[websiteId]/reports/breakdown/Breakdown.tsx index 4ff06631..053c6bb1 100644 --- a/src/app/(main)/websites/[websiteId]/reports/breakdown/Breakdown.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/breakdown/Breakdown.tsx @@ -30,7 +30,7 @@ export function Breakdown({ websiteId, parameters, startDate, endDate }: Breakdo ); return ( - + {parameters?.fields.map(field => { return ( diff --git a/src/app/(main)/websites/[websiteId]/reports/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/reports/funnels/Funnel.tsx index 2ca2e41b..d21548a5 100644 --- a/src/app/(main)/websites/[websiteId]/reports/funnels/Funnel.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/funnels/Funnel.tsx @@ -29,7 +29,7 @@ export function Funnel({ id, name, type, parameters, websiteId, startDate, endDa }); return ( - + diff --git a/src/app/(main)/websites/[websiteId]/reports/funnels/FunnelsPage.tsx b/src/app/(main)/websites/[websiteId]/reports/funnels/FunnelsPage.tsx index 4c7e831b..a479baeb 100644 --- a/src/app/(main)/websites/[websiteId]/reports/funnels/FunnelsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/funnels/FunnelsPage.tsx @@ -9,7 +9,7 @@ import { LoadingPanel } from '@/components/common/LoadingPanel'; import { Panel } from '@/components/common/Panel'; export function FunnelsPage({ websiteId }: { websiteId: string }) { - const { result } = useReportsQuery({ websiteId, type: 'funnel' }); + const { result, query } = useReportsQuery({ websiteId, type: 'funnel' }); const { dateRange: { startDate, endDate }, } = useDateRange(websiteId); @@ -20,7 +20,7 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) { - + {result?.data?.map((report: any) => ( diff --git a/src/app/(main)/websites/[websiteId]/reports/goals/Goal.tsx b/src/app/(main)/websites/[websiteId]/reports/goals/Goal.tsx index 8e2d453a..a3ef7515 100644 --- a/src/app/(main)/websites/[websiteId]/reports/goals/Goal.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/goals/Goal.tsx @@ -35,7 +35,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate const isPage = parameters?.type === 'page'; return ( - + diff --git a/src/app/(main)/websites/[websiteId]/reports/goals/GoalsPage.tsx b/src/app/(main)/websites/[websiteId]/reports/goals/GoalsPage.tsx index 01c91e0a..a843279f 100644 --- a/src/app/(main)/websites/[websiteId]/reports/goals/GoalsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/goals/GoalsPage.tsx @@ -9,7 +9,7 @@ import { LoadingPanel } from '@/components/common/LoadingPanel'; import { Panel } from '@/components/common/Panel'; export function GoalsPage({ websiteId }: { websiteId: string }) { - const { result } = useReportsQuery({ websiteId, type: 'goal' }); + const { result, query } = useReportsQuery({ websiteId, type: 'goal' }); const { dateRange: { startDate, endDate }, } = useDateRange(websiteId); @@ -20,7 +20,7 @@ export function GoalsPage({ websiteId }: { websiteId: string }) { - + {result?.data?.map((report: any) => ( diff --git a/src/app/(main)/websites/[websiteId]/reports/journeys/Journey.tsx b/src/app/(main)/websites/[websiteId]/reports/journeys/Journey.tsx index 72e138ca..e49392b1 100644 --- a/src/app/(main)/websites/[websiteId]/reports/journeys/Journey.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/journeys/Journey.tsx @@ -166,7 +166,7 @@ export function Journey({ }; return ( - +
{columns.map(({ visitorCount, nodes }, columnIndex) => { diff --git a/src/app/(main)/websites/[websiteId]/reports/retention/Retention.tsx b/src/app/(main)/websites/[websiteId]/reports/retention/Retention.tsx index 3031eee1..92b681d7 100644 --- a/src/app/(main)/websites/[websiteId]/reports/retention/Retention.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/retention/Retention.tsx @@ -1,6 +1,5 @@ import { ReactNode } from 'react'; -import { Grid, Row, Column, Text, Loading, Icon } from '@umami/react-zen'; -import { Empty } from '@/components/common/Empty'; +import { Grid, Row, Column, Text, Icon } from '@umami/react-zen'; import { Users } from '@/components/icons'; import { useMessages, useLocale, useResultQuery } from '@/components/hooks'; import { formatDate } from '@/lib/date'; @@ -28,14 +27,6 @@ export function Retention({ websiteId, days = DAYS, startDate, endDate }: Retent }, }); - if (isLoading) { - return ; - } - - if (!data) { - return ; - } - const rows = data.reduce((arr: any[], row: { date: any; visitors: any; day: any }) => { const { date, visitors, day } = row; if (day === 0) { @@ -44,7 +35,9 @@ export function Retention({ websiteId, days = DAYS, startDate, endDate }: Retent visitors, records: days .reduce((arr, day) => { - arr[day] = data.find(x => x.date === date && x.day === day); + arr[day] = data.find( + (x: { date: any; day: number }) => x.date === date && x.day === day, + ); return arr; }, []) .filter(n => n), @@ -56,7 +49,7 @@ export function Retention({ websiteId, days = DAYS, startDate, endDate }: Retent const totalDays = rows.length; return ( - + ( @@ -52,7 +51,7 @@ export function Revenue({ websiteId, startDate, endDate }: RevenueProps) { [countryNames, locale], ); - const chartData = useMemo(() => { + const chartData: any = useMemo(() => { if (!data) return []; const map = (data.chart as any[]).reduce((obj, { x, t, y }) => { @@ -114,9 +113,9 @@ export function Revenue({ websiteId, startDate, endDate }: RevenueProps) { - + - + {metrics?.map(({ label, value, formatValue }) => { return ( @@ -125,13 +124,12 @@ export function Revenue({ websiteId, startDate, endDate }: RevenueProps) { diff --git a/src/app/(main)/websites/[websiteId]/reports/utm/UTM.tsx b/src/app/(main)/websites/[websiteId]/reports/utm/UTM.tsx index 02d0b100..fa9383e1 100644 --- a/src/app/(main)/websites/[websiteId]/reports/utm/UTM.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/utm/UTM.tsx @@ -23,10 +23,9 @@ export function UTM({ websiteId, startDate, endDate }: UTMProps) { endDate, }, }); - const isEmpty = !Object.keys(data || {})?.length; return ( - + {UTM_PARAMS.map(param => { const items = toArray(data?.[param]); @@ -61,7 +60,7 @@ export function UTM({ websiteId, startDate, endDate }: UTMProps) { /> - + diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx index b890bbd5..1da45000 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx @@ -3,33 +3,36 @@ import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsi import { MetricCard } from '@/components/metrics/MetricCard'; import { MetricsBar } from '@/components/metrics/MetricsBar'; import { formatLongNumber } from '@/lib/format'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; export function SessionsMetricsBar({ websiteId }: { websiteId: string }) { const { formatMessage, labels } = useMessages(); - const { data, isLoading, isFetched, error } = useWebsiteSessionStatsQuery(websiteId); + const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId); return ( - - - - - - + + + + + + + + ); } diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx index dba6806e..78c5ccdc 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx @@ -36,65 +36,70 @@ export function SessionsWeekly({ websiteId }: { websiteId: string }) { : []; return ( - + - -   - {Array(24) - .fill(null) - .map((_, i) => { - const label = format(addHours(startOfDay(new Date()), i), 'p', { locale: dateLocale }) - .replace(/\D00 ?/, '') - .toLowerCase(); - return ( - - - {label} - - - ); - })} - - {data && - daysOfWeek.map((index: number) => { - const day = data[index]; - return ( - - - - {format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })} - - - {day?.map((count: number, j) => { - const pct = count / max; + {data && ( + <> + +   + {Array(24) + .fill(null) + .map((_, i) => { + const label = format(addHours(startOfDay(new Date()), i), 'p', { + locale: dateLocale, + }) + .replace(/\D00 ?/, '') + .toLowerCase(); return ( - - - - - - - {`${formatMessage( - labels.visitors, - )}: ${count}`} - + + + {label} + + ); })} - - ); - })} + + {daysOfWeek.map((index: number) => { + const day = data[index]; + return ( + + + + {format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })} + + + {day?.map((count: number, j) => { + const pct = count / max; + return ( + + + + + + + {`${formatMessage( + labels.visitors, + )}: ${count}`} + + ); + })} + + ); + })} + + )} ); diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.tsx index dab2c87b..a5fc93aa 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.tsx @@ -1,8 +1,20 @@ import { isSameDay } from 'date-fns'; -import { Icon, StatusLight, Column, Row, Heading, Text, Button } from '@umami/react-zen'; +import { + Icon, + StatusLight, + Column, + Row, + Heading, + Text, + Button, + DialogTrigger, + Popover, + Dialog, +} from '@umami/react-zen'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { Bolt, Eye, FileText } from '@/components/icons'; import { useSessionActivityQuery, useTimezone } from '@/components/hooks'; +import { EventData } from '@/components/metrics/EventData'; export function SessionActivity({ websiteId, @@ -25,7 +37,7 @@ export function SessionActivity({ let lastDay = null; return ( - + {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => { const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt)); @@ -41,15 +53,7 @@ export function SessionActivity({ {eventName ? : } {eventName || urlPath} - {hasData > 0 && ( - - )} + {hasData > 0 && } @@ -59,3 +63,22 @@ export function SessionActivity({ ); } + +const PropertiesButton = props => { + return ( + + + + + + + + + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.tsx index 70e1a171..849e0b7d 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionData.tsx @@ -6,10 +6,9 @@ import { LoadingPanel } from '@/components/common/LoadingPanel'; export function SessionData({ websiteId, sessionId }: { websiteId: string; sessionId: string }) { const { data, isLoading, error } = useSessionDataQuery(websiteId, sessionId); - const isEmpty = !data?.length; return ( - + {!data?.length && } {data?.map(({ dataKey, dataType, stringValue }) => { diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx index 27e05860..7c00903a 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx @@ -20,7 +20,7 @@ export function SessionDetailsPage({ const { formatMessage, labels } = useMessages(); return ( - + @@ -28,7 +28,6 @@ export function SessionDetailsPage({ - diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx index 5aa3716d..e25be9ad 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx @@ -7,7 +7,7 @@ export function SessionStats({ data }) { const { formatMessage, labels } = useMessages(); return ( - + diff --git a/src/app/Providers.tsx b/src/app/Providers.tsx index 8d5141de..77be5201 100644 --- a/src/app/Providers.tsx +++ b/src/app/Providers.tsx @@ -13,6 +13,7 @@ const client = new QueryClient({ queries: { retry: false, refetchOnWindowFocus: false, + staleTime: 1000 * 60, }, }, }); diff --git a/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts b/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts new file mode 100644 index 00000000..8eeb7171 --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { unauthorized, json } from '@/lib/response'; +import { canViewWebsite } from '@/lib/auth'; +import { getEventData } from '@/queries/sql/events/getEventData'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; eventId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, eventId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getEventData(eventId); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/stats/route.ts b/src/app/api/websites/[websiteId]/stats/route.ts index c146271f..70c0110e 100644 --- a/src/app/api/websites/[websiteId]/stats/route.ts +++ b/src/app/api/websites/[websiteId]/stats/route.ts @@ -45,19 +45,11 @@ export async function GET( endDate, }); - const prevPeriod = await getWebsiteStats(websiteId, { + const previous = await getWebsiteStats(websiteId, { ...filters, startDate: compareStartDate, endDate: compareEndDate, }); - const stats = Object.keys(metrics[0]).reduce((obj, key) => { - obj[key] = { - value: Number(metrics[0][key]) || 0, - prev: Number(prevPeriod[0][key]) || 0, - }; - return obj; - }, {}); - - return json(stats); + return json({ ...metrics, previous }); } diff --git a/src/app/sso/SSOPage.tsx b/src/app/sso/SSOPage.tsx index 4d737f94..b8893a6c 100644 --- a/src/app/sso/SSOPage.tsx +++ b/src/app/sso/SSOPage.tsx @@ -18,5 +18,5 @@ export function SSOPage() { } }, [router, url, token]); - return ; + return ; } diff --git a/src/components/common/DataGrid.tsx b/src/components/common/DataGrid.tsx index def56509..1a70efef 100644 --- a/src/components/common/DataGrid.tsx +++ b/src/components/common/DataGrid.tsx @@ -1,7 +1,6 @@ import { ReactNode } from 'react'; -import { Loading, SearchField, Row, Column } from '@umami/react-zen'; +import { SearchField, Row, Column } from '@umami/react-zen'; import { useMessages, useNavigation } from '@/components/hooks'; -import { Empty } from '@/components/common/Empty'; import { Pager } from '@/components/common/Pager'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { PagedQueryResult } from '@/lib/types'; @@ -24,16 +23,14 @@ export function DataGrid({ allowSearch = true, allowPaging = true, autoFocus, - renderEmpty, children, }: DataTableProps) { - const { formatMessage, labels, messages } = useMessages(); + const { formatMessage, labels } = useMessages(); const { result, params, setParams, query } = queryResult || {}; - const { error, isLoading, isFetched } = query || {}; + const { error, isLoading, isFetching } = query || {}; const { page, pageSize, count, data } = result || {}; const { search } = params || {}; const hasData = Boolean(!isLoading && data?.length); - const noResults = Boolean(search && !hasData); const { router, renderUrl } = useNavigation(); const handleSearch = (search: string) => { @@ -46,7 +43,7 @@ export function DataGrid({ }; return ( - + {allowSearch && (hasData || search) && ( )} - + {hasData ? (typeof children === 'function' ? children(result) : children) : null} - {isLoading && } - {!isLoading && !hasData && !search && (renderEmpty ? renderEmpty() : )} - {!isLoading && noResults && } {allowPaging && hasData && ( diff --git a/src/components/common/LoadingPanel.tsx b/src/components/common/LoadingPanel.tsx index 4f5be375..fd5561a2 100644 --- a/src/components/common/LoadingPanel.tsx +++ b/src/components/common/LoadingPanel.tsx @@ -1,32 +1,59 @@ import { ReactNode } from 'react'; -import { Spinner, Dots, Column, type ColumnProps } from '@umami/react-zen'; +import { Loading, Column, type ColumnProps } from '@umami/react-zen'; import { ErrorMessage } from '@/components/common/ErrorMessage'; import { Empty } from '@/components/common/Empty'; +export interface LoadingPanelProps extends ColumnProps { + data?: any; + error?: Error; + isEmpty?: boolean; + isLoading?: boolean; + isFetching?: boolean; + loadingIcon?: 'dots' | 'spinner'; + renderEmpty?: () => ReactNode; + children: ReactNode; +} + export function LoadingPanel({ + data, error, isEmpty, - isFetched, isLoading, + isFetching, loadingIcon = 'dots', renderEmpty = () => , children, ...props -}: { - error?: Error; - isEmpty?: boolean; - isFetched?: boolean; - isLoading?: boolean; - loadingIcon?: 'dots' | 'spinner'; - renderEmpty?: () => ReactNode; - children: ReactNode; -} & ColumnProps) { +}: LoadingPanelProps) { + const empty = isEmpty ?? checkEmpty(data); + return ( - - {isLoading && !isFetched && (loadingIcon === 'dots' ? : )} + + {/* Show loading spinner only if no data exists */} + {(isLoading || isFetching) && !data && } + + {/* Show error */} {error && } - {!error && !isLoading && isEmpty && renderEmpty()} - {!error && !isLoading && !isEmpty && children} + + {/* Show empty state (once loaded) */} + {!error && !isLoading && !isFetching && empty && renderEmpty()} + + {/* Show main content when data exists */} + {!error && !empty && children} ); } + +function checkEmpty(data: any) { + if (!data) return false; + + if (Array.isArray(data)) { + return data.length <= 0; + } + + if (typeof data === 'object') { + return Object.keys(data).length <= 0; + } + + return !!data; +} diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index b815a28e..28a55345 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -1,5 +1,6 @@ 'use client'; export * from './queries/useActiveUsersQuery'; +export * from './queries/useEventDataQuery'; export * from './queries/useEventDataEventsQuery'; export * from './queries/useEventDataPropertiesQuery'; export * from './queries/useEventDataValuesQuery'; @@ -22,7 +23,6 @@ export * from './queries/useTeamWebsitesQuery'; export * from './queries/useTeamMembersQuery'; export * from './queries/useUserQuery'; export * from './queries/useUsersQuery'; -export * from './queries/useUTMQuery'; export * from './queries/useWebsiteQuery'; export * from './queries/useWebsites'; export * from './queries/useWebsiteEventsQuery'; diff --git a/src/components/hooks/queries/useActiveUsersQuery.ts b/src/components/hooks/queries/useActiveUsersQuery.ts index 3b4b025d..9335b75a 100644 --- a/src/components/hooks/queries/useActiveUsersQuery.ts +++ b/src/components/hooks/queries/useActiveUsersQuery.ts @@ -1,10 +1,7 @@ import { useApi } from '../useApi'; -import { UseQueryOptions } from '@tanstack/react-query'; +import { ReactQueryOptions } from '@/lib/types'; -export function useActyiveUsersQuery( - websiteId: string, - options?: Omit, -) { +export function useActyiveUsersQuery(websiteId: string, options?: ReactQueryOptions) { const { get, useQuery } = useApi(); return useQuery({ queryKey: ['websites:active', websiteId], diff --git a/src/components/hooks/queries/useEventDataEventsQuery.ts b/src/components/hooks/queries/useEventDataEventsQuery.ts index b03906b5..73427b1d 100644 --- a/src/components/hooks/queries/useEventDataEventsQuery.ts +++ b/src/components/hooks/queries/useEventDataEventsQuery.ts @@ -1,11 +1,8 @@ import { useApi } from '../useApi'; -import { UseQueryOptions } from '@tanstack/react-query'; import { useFilterParams } from '../useFilterParams'; +import { ReactQueryOptions } from '@/lib/types'; -export function useEventDataEventsQuery( - websiteId: string, - options?: Omit, -) { +export function useEventDataEventsQuery(websiteId: string, options?: ReactQueryOptions) { const { get, useQuery } = useApi(); const params = useFilterParams(websiteId); diff --git a/src/components/hooks/queries/useEventDataPropertiesQuery.ts b/src/components/hooks/queries/useEventDataPropertiesQuery.ts index 97f422eb..0e6735b6 100644 --- a/src/components/hooks/queries/useEventDataPropertiesQuery.ts +++ b/src/components/hooks/queries/useEventDataPropertiesQuery.ts @@ -1,11 +1,8 @@ -import { UseQueryOptions } from '@tanstack/react-query'; import { useApi } from '../useApi'; import { useFilterParams } from '../useFilterParams'; +import { ReactQueryOptions } from '@/lib/types'; -export function useEventDataPropertiesQuery( - websiteId: string, - options?: Omit, -) { +export function useEventDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) { const { get, useQuery } = useApi(); const params = useFilterParams(websiteId); diff --git a/src/components/hooks/queries/useEventDataQuery.ts b/src/components/hooks/queries/useEventDataQuery.ts new file mode 100644 index 00000000..0901cdd1 --- /dev/null +++ b/src/components/hooks/queries/useEventDataQuery.ts @@ -0,0 +1,19 @@ +import { useApi } from '../useApi'; +import { useFilterParams } from '../useFilterParams'; +import { ReactQueryOptions } from '@/lib/types'; + +export function useEventDataQuery( + websiteId: string, + eventId: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const params = useFilterParams(websiteId); + + return useQuery({ + queryKey: ['websites:event-data', { websiteId, eventId, ...params }], + queryFn: () => get(`/websites/${websiteId}/event-data/${eventId}`, { ...params }), + enabled: !!(websiteId && eventId), + ...options, + }); +} diff --git a/src/components/hooks/queries/useEventDataValuesQuery.ts b/src/components/hooks/queries/useEventDataValuesQuery.ts index cc9e55b5..6871214e 100644 --- a/src/components/hooks/queries/useEventDataValuesQuery.ts +++ b/src/components/hooks/queries/useEventDataValuesQuery.ts @@ -1,12 +1,12 @@ -import { UseQueryOptions } from '@tanstack/react-query'; import { useApi } from '../useApi'; import { useFilterParams } from '../useFilterParams'; +import { ReactQueryOptions } from '@/lib/types'; export function useEventDataValuesQuery( websiteId: string, eventName: string, propertyName: string, - options?: Omit, + options?: ReactQueryOptions, ) { const { get, useQuery } = useApi(); const params = useFilterParams(websiteId); diff --git a/src/components/hooks/queries/useLoginQuery.ts b/src/components/hooks/queries/useLoginQuery.ts index 411e0d51..23949c0d 100644 --- a/src/components/hooks/queries/useLoginQuery.ts +++ b/src/components/hooks/queries/useLoginQuery.ts @@ -1,4 +1,3 @@ -import { UseQueryResult } from '@tanstack/react-query'; import { useApp, setUser } from '@/store/app'; import { useApi } from '../useApi'; @@ -7,7 +6,7 @@ const selector = (state: { user: any }) => state.user; export function useLoginQuery(): { user: any; setUser: (data: any) => void; -} & UseQueryResult { +} { const { post, useQuery } = useApi(); const user = useApp(selector); diff --git a/src/components/hooks/queries/useReportsQuery.ts b/src/components/hooks/queries/useReportsQuery.ts index 002f6f9b..8c05794f 100644 --- a/src/components/hooks/queries/useReportsQuery.ts +++ b/src/components/hooks/queries/useReportsQuery.ts @@ -1,8 +1,12 @@ import { useApi } from '../useApi'; import { usePagedQuery } from '../usePagedQuery'; import { useModified } from '../useModified'; +import { ReactQueryOptions } from '@/lib/types'; -export function useReportsQuery({ websiteId, type }: { websiteId: string; type?: string }) { +export function useReportsQuery( + { websiteId, type }: { websiteId: string; type?: string }, + options?: ReactQueryOptions, +) { const { modified } = useModified(`reports:${type}`); const { get } = useApi(); @@ -10,5 +14,6 @@ export function useReportsQuery({ websiteId, type }: { websiteId: string; type?: queryKey: ['reports', { websiteId, type, modified }], queryFn: async () => get('/reports', { websiteId, type }), enabled: !!websiteId && !!type, + ...options, }); } diff --git a/src/components/hooks/queries/useResultQuery.ts b/src/components/hooks/queries/useResultQuery.ts index 3ca6e23a..be84193d 100644 --- a/src/components/hooks/queries/useResultQuery.ts +++ b/src/components/hooks/queries/useResultQuery.ts @@ -1,10 +1,10 @@ import { useApi } from '@/components/hooks'; -import { UseQueryOptions, QueryKey } from '@tanstack/react-query'; +import { ReactQueryOptions } from '@/lib/types'; export function useResultQuery( type: string, params?: { [key: string]: any }, - options?: Omit, 'queryKey' | 'queryFn'>, + options?: ReactQueryOptions, ) { const { post, useQuery } = useApi(); diff --git a/src/components/hooks/queries/useSessionDataPropertiesQuery.ts b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts index b694cf37..470f8a09 100644 --- a/src/components/hooks/queries/useSessionDataPropertiesQuery.ts +++ b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts @@ -1,11 +1,8 @@ import { useApi } from '../useApi'; -import { UseQueryOptions } from '@tanstack/react-query'; import { useFilterParams } from '../useFilterParams'; +import { ReactQueryOptions } from '@/lib/types'; -export function useSessionDataPropertiesQuery( - websiteId: string, - options?: Omit, -) { +export function useSessionDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) { const { get, useQuery } = useApi(); const params = useFilterParams(websiteId); diff --git a/src/components/hooks/queries/useSessionDataValuesQuery.ts b/src/components/hooks/queries/useSessionDataValuesQuery.ts index 8a75b1a3..e9b846e8 100644 --- a/src/components/hooks/queries/useSessionDataValuesQuery.ts +++ b/src/components/hooks/queries/useSessionDataValuesQuery.ts @@ -1,11 +1,11 @@ import { useApi } from '../useApi'; -import { UseQueryOptions } from '@tanstack/react-query'; import { useFilterParams } from '../useFilterParams'; +import { ReactQueryOptions } from '@/lib/types'; export function useSessionDataValuesQuery( websiteId: string, propertyName: string, - options?: Omit, + options?: ReactQueryOptions, ) { const { get, useQuery } = useApi(); const params = useFilterParams(websiteId); diff --git a/src/components/hooks/queries/useUTMQuery.ts b/src/components/hooks/queries/useUTMQuery.ts deleted file mode 100644 index f89200a7..00000000 --- a/src/components/hooks/queries/useUTMQuery.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useApi } from '../useApi'; -import { useFilterParams } from '../useFilterParams'; -import { UseQueryOptions } from '@tanstack/react-query'; - -export function useUTMQuery( - websiteId: string, - queryParams?: { type: string; limit?: number; search?: string; startAt?: number; endAt?: number }, - options?: Omit void }, 'queryKey' | 'queryFn'>, -) { - const { get, useQuery } = useApi(); - const filterParams = useFilterParams(websiteId); - - return useQuery({ - queryKey: ['utm', websiteId, { ...filterParams, ...queryParams }], - queryFn: () => - get(`/websites/${websiteId}/utm`, { websiteId, ...filterParams, ...queryParams }), - enabled: !!websiteId, - ...options, - }); -} diff --git a/src/components/hooks/queries/useWebsiteEventsQuery.ts b/src/components/hooks/queries/useWebsiteEventsQuery.ts index c36db405..8699ab31 100644 --- a/src/components/hooks/queries/useWebsiteEventsQuery.ts +++ b/src/components/hooks/queries/useWebsiteEventsQuery.ts @@ -1,12 +1,9 @@ import { useApi } from '../useApi'; -import { UseQueryOptions } from '@tanstack/react-query'; import { useFilterParams } from '../useFilterParams'; import { usePagedQuery } from '../usePagedQuery'; +import { ReactQueryOptions } from '@/lib/types'; -export function useWebsiteEventsQuery( - websiteId: string, - options?: Omit, -) { +export function useWebsiteEventsQuery(websiteId: string, options?: ReactQueryOptions) { const { get } = useApi(); const params = useFilterParams(websiteId); diff --git a/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts b/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts index 6e0267f4..c2b2a1e2 100644 --- a/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts +++ b/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts @@ -1,11 +1,8 @@ import { useApi } from '../useApi'; -import { UseQueryOptions } from '@tanstack/react-query'; import { useFilterParams } from '../useFilterParams'; +import { ReactQueryOptions } from '@/lib/types'; -export function useWebsiteEventsSeriesQuery( - websiteId: string, - options?: Omit, -) { +export function useWebsiteEventsSeriesQuery(websiteId: string, options?: ReactQueryOptions) { const { get, useQuery } = useApi(); const params = useFilterParams(websiteId); diff --git a/src/components/hooks/queries/useWebsiteMetricsQuery.ts b/src/components/hooks/queries/useWebsiteMetricsQuery.ts index 801a7e51..89adf818 100644 --- a/src/components/hooks/queries/useWebsiteMetricsQuery.ts +++ b/src/components/hooks/queries/useWebsiteMetricsQuery.ts @@ -1,18 +1,24 @@ -import { UseQueryOptions } from '@tanstack/react-query'; +import { keepPreviousData } from '@tanstack/react-query'; import { useApi } from '../useApi'; import { useFilterParams } from '../useFilterParams'; import { useSearchParams } from 'next/navigation'; +import { ReactQueryOptions } from '@/lib/types'; + +export type WebsiteMetricsData = { + x: string; + y: number; +}[]; export function useWebsiteMetricsQuery( websiteId: string, queryParams: { type: string; limit?: number; search?: string; startAt?: number; endAt?: number }, - options?: Omit void }, 'queryKey' | 'queryFn'>, + options?: ReactQueryOptions, ) { const { get, useQuery } = useApi(); const filterParams = useFilterParams(websiteId); const searchParams = useSearchParams(); - return useQuery({ + return useQuery({ queryKey: [ 'websites:metrics', { @@ -21,18 +27,14 @@ export function useWebsiteMetricsQuery( ...queryParams, }, ], - queryFn: async () => { - const data = await get(`/websites/${websiteId}/metrics`, { + queryFn: async () => + get(`/websites/${websiteId}/metrics`, { ...filterParams, [searchParams.get('view')]: undefined, ...queryParams, - }); - - options?.onDataLoad?.(data); - - return data; - }, + }), enabled: !!websiteId, + placeholderData: keepPreviousData, ...options, }); } diff --git a/src/components/hooks/queries/useWebsitePageviewsQuery.ts b/src/components/hooks/queries/useWebsitePageviewsQuery.ts index a2e71df3..d4793052 100644 --- a/src/components/hooks/queries/useWebsitePageviewsQuery.ts +++ b/src/components/hooks/queries/useWebsitePageviewsQuery.ts @@ -1,18 +1,22 @@ -import { UseQueryOptions } from '@tanstack/react-query'; import { useApi } from '../useApi'; import { useFilterParams } from '../useFilterParams'; +import { ReactQueryOptions } from '@/lib/types'; + +export interface WebsitePageviewsData { + pageviews: { x: string; y: number }[]; + sessions: { x: string; y: number }[]; +} export function useWebsitePageviewsQuery( - websiteId: string, - compare?: string, - options?: Omit, + { websiteId, compareMode }: { websiteId: string; compareMode?: string }, + options?: ReactQueryOptions, ) { const { get, useQuery } = useApi(); - const params = useFilterParams(websiteId); + const filterParams = useFilterParams(websiteId); - return useQuery({ - queryKey: ['websites:pageviews', { websiteId, ...params, compare }], - queryFn: () => get(`/websites/${websiteId}/pageviews`, { ...params, compare }), + return useQuery({ + queryKey: ['websites:pageviews', { websiteId, compareMode, ...filterParams }], + queryFn: () => get(`/websites/${websiteId}/pageviews`, { compareMode, ...filterParams }), enabled: !!websiteId, ...options, }); diff --git a/src/components/hooks/queries/useWebsiteStatsQuery.ts b/src/components/hooks/queries/useWebsiteStatsQuery.ts index 5c761fce..2d5595e0 100644 --- a/src/components/hooks/queries/useWebsiteStatsQuery.ts +++ b/src/components/hooks/queries/useWebsiteStatsQuery.ts @@ -1,15 +1,31 @@ +import { UseQueryOptions } from '@tanstack/react-query'; import { useApi } from '../useApi'; import { useFilterParams } from '../useFilterParams'; +export interface WebsiteStatsData { + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; + previous: { + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; + }; +} + export function useWebsiteStatsQuery( websiteId: string, compare?: string, - options?: { [key: string]: string }, + options?: UseQueryOptions, ) { const { get, useQuery } = useApi(); const params = useFilterParams(websiteId); - return useQuery({ + return useQuery({ queryKey: ['websites:stats', { websiteId, ...params, compare }], queryFn: () => get(`/websites/${websiteId}/stats`, { ...params, compare }), enabled: !!websiteId, diff --git a/src/components/input/DateFilter.tsx b/src/components/input/DateFilter.tsx index d8ac0b08..4503048b 100644 --- a/src/components/input/DateFilter.tsx +++ b/src/components/input/DateFilter.tsx @@ -100,13 +100,13 @@ export function DateFilter({ }; return ( - +