diff --git a/client/src/api/admin/sites.ts b/client/src/api/admin/sites.ts index e3b2e4652..f1cc41d14 100644 --- a/client/src/api/admin/sites.ts +++ b/client/src/api/admin/sites.ts @@ -28,6 +28,21 @@ export type SiteResponse = { trackIp?: boolean; }; +export type SiteMetrics = { + users: number; + pageviews: number; + bounceRate: number; + sessionDuration: number; + pagesPerSession: number; + sessions: number; + usersChange: number; + pageviewsChange: number; + sessionsChange: number; + bounceRateChange: number; + sessionDurationChange: number; + pagesPerSessionChange: number; +}; + export type GetSitesFromOrgResponse = { organization: { id: string; @@ -54,6 +69,7 @@ export type GetSitesFromOrgResponse = { blockBots: boolean; sessionsLast24Hours: number; isOwner: boolean; + metrics?: SiteMetrics; }>; subscription: { monthlyEventCount: number; @@ -65,11 +81,21 @@ export type GetSitesFromOrgResponse = { }; }; -export function useGetSitesFromOrg(organizationId?: string) { +export function useGetSitesFromOrg( + organizationId?: string, + options?: { includeMetrics?: boolean; timePeriod?: "24h" | "7d" | "30d" } +) { + const includeMetrics = options?.includeMetrics ?? false; + const timePeriod = options?.timePeriod ?? "24h"; + return useQuery({ - queryKey: ["get-sites-from-org", organizationId], + queryKey: ["get-sites-from-org", organizationId, includeMetrics, timePeriod], queryFn: () => { - return authedFetch(`/get-sites-from-org/${organizationId}`); + const params = new URLSearchParams(); + if (includeMetrics) params.append("includeMetrics", "true"); + if (timePeriod) params.append("timePeriod", timePeriod); + const queryString = params.toString(); + return authedFetch(`/get-sites-from-org/${organizationId}${queryString ? `?${queryString}` : ""}`); }, staleTime: 60000, // 1 minute enabled: !!organizationId, diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx index 574620651..b9988590e 100644 --- a/client/src/app/page.tsx +++ b/client/src/app/page.tsx @@ -1,13 +1,15 @@ "use client"; -import { Plus } from "lucide-react"; -import { useState } from "react"; +import { LayoutGrid, Plus, Table2 } from "lucide-react"; +import { useEffect, useState } from "react"; import { useUserOrganizations } from "../api/admin/organizations"; import { useGetSitesFromOrg } from "../api/admin/sites"; import { CreateOrganizationDialog } from "../components/CreateOrganizationDialog"; import { NoOrganization } from "../components/NoOrganization"; import { OrganizationSelector } from "../components/OrganizationSelector"; import { SiteCard } from "../components/SiteCard"; +import { SitesOverviewTable } from "../components/SitesOverviewTable"; +import { SitesSummaryStats } from "../components/SitesSummaryStats"; import { StandardPage } from "../components/StandardPage"; import { Button } from "../components/ui/button"; import { Card, CardDescription, CardTitle } from "../components/ui/card"; @@ -15,12 +17,49 @@ import { useSetPageTitle } from "../hooks/useSetPageTitle"; import { authClient } from "../lib/auth"; import { AddSite } from "./components/AddSite"; +type ViewMode = "cards" | "table"; +type TimePeriod = "24h" | "7d" | "30d"; + export default function Home() { useSetPageTitle("Rybbit ยท Home"); const { data: activeOrganization, isPending } = authClient.useActiveOrganization(); - const { data: sites, refetch: refetchSites, isLoading: isLoadingSites } = useGetSitesFromOrg(activeOrganization?.id); + // Load view mode and time period from localStorage + const [viewMode, setViewMode] = useState("cards"); + const [timePeriod, setTimePeriod] = useState("24h"); + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + const savedViewMode = localStorage.getItem("sitesViewMode") as ViewMode; + const savedTimePeriod = localStorage.getItem("sitesTimePeriod") as TimePeriod; + if (savedViewMode) setViewMode(savedViewMode); + if (savedTimePeriod) setTimePeriod(savedTimePeriod); + }, []); + + const handleViewModeChange = (mode: ViewMode) => { + setViewMode(mode); + if (isClient) { + localStorage.setItem("sitesViewMode", mode); + } + }; + + const handleTimePeriodChange = (period: TimePeriod) => { + setTimePeriod(period); + if (isClient) { + localStorage.setItem("sitesTimePeriod", period); + } + }; + + const { + data: sites, + refetch: refetchSites, + isLoading: isLoadingSites, + } = useGetSitesFromOrg(activeOrganization?.id, { + includeMetrics: viewMode === "table", + timePeriod: timePeriod, + }); const { data: userOrganizationsData, @@ -53,41 +92,114 @@ export default function Home() { refetchSites(); }; + const timePeriodLabel = { + "24h": "Last 24 hours", + "7d": "Last 7 days", + "30d": "Last 30 days", + }; + return (
- {/*
{sites?.length} Websites
*/}
+ {/* Organization required message */} {hasNoOrganizations && } -
- {/* Sites list */} - - {sites?.sites?.map(site => { - return ; - })} - - {/* No websites message */} - {hasNoSites && ( - - No websites yet - Add your first website to start tracking analytics - - - Add Website - - } - /> - - )} -
+ {/* View controls */} + {shouldShowSites && sites?.sites && sites.sites.length > 0 && ( +
+ {/* View mode toggle */} +
+ + +
+ + {/* Time period selector */} +
+ {(["24h", "7d", "30d"] as TimePeriod[]).map(period => ( + + ))} +
+
+ )} + + {/* Cards view */} + {viewMode === "cards" && ( +
+ {sites?.sites?.map(site => { + return ; + })} + + {/* No websites message */} + {hasNoSites && ( + + No websites yet + Add your first website to start tracking analytics + + + Add Website + + } + /> + + )} +
+ )} + + {/* Table view */} + {viewMode === "table" && ( +
+ {hasNoSites ? ( + + No websites yet + Add your first website to start tracking analytics + + + Add Website + + } + /> + + ) : ( + <> + + + + )} +
+ )} { + if (value === 0) return -; + + const isPositive = value > 0; + const isGood = reverseColors ? !isPositive : isPositive; + + return ( + + {isPositive ? : } + {Math.abs(value).toFixed(1)}% + + ); +}; + +export function SitesOverviewTable({ sites, isLoading }: SitesOverviewTableProps) { + const [sortField, setSortField] = useState("sessions"); + const [sortDirection, setSortDirection] = useState("desc"); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + } else { + setSortField(field); + setSortDirection("desc"); + } + }; + + const sortedSites = useMemo(() => { + if (!sites) return []; + + return [...sites].sort((a, b) => { + let aValue: number | string = 0; + let bValue: number | string = 0; + + switch (sortField) { + case "domain": + aValue = a.domain; + bValue = b.domain; + break; + case "users": + aValue = a.metrics?.users ?? 0; + bValue = b.metrics?.users ?? 0; + break; + case "sessions": + aValue = a.metrics?.sessions ?? 0; + bValue = b.metrics?.sessions ?? 0; + break; + case "pageviews": + aValue = a.metrics?.pageviews ?? 0; + bValue = b.metrics?.pageviews ?? 0; + break; + case "bounceRate": + aValue = a.metrics?.bounceRate ?? 0; + bValue = b.metrics?.bounceRate ?? 0; + break; + case "sessionDuration": + aValue = a.metrics?.sessionDuration ?? 0; + bValue = b.metrics?.sessionDuration ?? 0; + break; + case "pagesPerSession": + aValue = a.metrics?.pagesPerSession ?? 0; + bValue = b.metrics?.pagesPerSession ?? 0; + break; + } + + if (typeof aValue === "string" && typeof bValue === "string") { + return sortDirection === "asc" ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue); + } + + return sortDirection === "asc" ? (aValue as number) - (bValue as number) : (bValue as number) - (aValue as number); + }); + }, [sites, sortField, sortDirection]); + + const SortIcon = ({ field }: { field: SortField }) => { + if (sortField !== field) return ; + return sortDirection === "asc" ? ( + + ) : ( + + ); + }; + + const totals = useMemo(() => { + if (!sites || sites.length === 0) return null; + + const total = sites.reduce( + (acc, site) => ({ + users: acc.users + (site.metrics?.users ?? 0), + sessions: acc.sessions + (site.metrics?.sessions ?? 0), + pageviews: acc.pageviews + (site.metrics?.pageviews ?? 0), + sessionDuration: acc.sessionDuration + (site.metrics?.sessionDuration ?? 0), + pagesPerSession: acc.pagesPerSession + (site.metrics?.pagesPerSession ?? 0), + bounceRate: acc.bounceRate + (site.metrics?.bounceRate ?? 0), + count: acc.count + 1, + }), + { + users: 0, + sessions: 0, + pageviews: 0, + sessionDuration: 0, + pagesPerSession: 0, + bounceRate: 0, + count: 0, + } + ); + + return { + users: total.users, + sessions: total.sessions, + pageviews: total.pageviews, + avgSessionDuration: total.sessionDuration / total.count, + avgPagesPerSession: total.pagesPerSession / total.count, + avgBounceRate: total.bounceRate / total.count, + }; + }, [sites]); + + if (isLoading) { + return ( +
+ + + + Domain + Users + Sessions + Pageviews + Bounce Rate + Avg Duration + Pages/Session + + + + {[1, 2, 3].map(i => ( + + + + + + + + + + + + + + + + + + + + + + + + ))} + +
+
+ ); + } + + if (!sites || sites.length === 0) { + return ( +
+

No sites available

+
+ ); + } + + return ( +
+
+ + + + handleSort("domain")} + > +
+ Domain + +
+
+ handleSort("users")} + > +
+ Users + +
+
+ handleSort("sessions")} + > +
+ Sessions + +
+
+ handleSort("pageviews")} + > +
+ Pageviews + +
+
+ handleSort("bounceRate")} + > +
+ Bounce Rate + +
+
+ handleSort("sessionDuration")} + > +
+ Avg Duration + +
+
+ handleSort("pagesPerSession")} + > +
+ Pages/Session + +
+
+
+
+ + {sortedSites.map(site => { + const metrics = site.metrics; + return ( + + + + + {site.domain} + + + +
+ {metrics?.users.toLocaleString() ?? "-"} + {metrics && } +
+
+ +
+ {metrics?.sessions.toLocaleString() ?? "-"} + {metrics && } +
+
+ +
+ {metrics?.pageviews.toLocaleString() ?? "-"} + {metrics && } +
+
+ +
+ {metrics?.bounceRate.toFixed(1) ?? "-"}% + {metrics && } +
+
+ +
+ {metrics ? formatDuration(metrics.sessionDuration) : "-"} + {metrics && } +
+
+ +
+ {metrics?.pagesPerSession.toFixed(2) ?? "-"} + {metrics && } +
+
+
+ ); + })} + + {/* Totals Row */} + {totals && ( + + Total ({sites.length} sites) + {totals.users.toLocaleString()} + {totals.sessions.toLocaleString()} + {totals.pageviews.toLocaleString()} + {totals.avgBounceRate.toFixed(1)}% + {formatDuration(totals.avgSessionDuration)} + {totals.avgPagesPerSession.toFixed(2)} + + )} +
+
+
+
+ ); +} diff --git a/client/src/components/SitesSummaryStats.tsx b/client/src/components/SitesSummaryStats.tsx new file mode 100644 index 000000000..571889d9b --- /dev/null +++ b/client/src/components/SitesSummaryStats.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { Activity, Eye, MousePointerClick, TrendingDown, TrendingUp, Users } from "lucide-react"; +import { useMemo } from "react"; +import { GetSitesFromOrgResponse } from "../api/admin/sites"; +import { formatDuration } from "../lib/dateTimeUtils"; +import { Card } from "./ui/card"; + +interface SitesSummaryStatsProps { + sites: GetSitesFromOrgResponse["sites"]; + isLoading?: boolean; +} + +export function SitesSummaryStats({ sites, isLoading }: SitesSummaryStatsProps) { + const stats = useMemo(() => { + if (!sites || sites.length === 0) { + return { + totalSites: 0, + totalUsers: 0, + totalSessions: 0, + totalPageviews: 0, + avgBounceRate: 0, + avgSessionDuration: 0, + avgPagesPerSession: 0, + usersChange: 0, + sessionsChange: 0, + pageviewsChange: 0, + }; + } + + const totals = sites.reduce( + (acc, site) => ({ + users: acc.users + (site.metrics?.users ?? 0), + sessions: acc.sessions + (site.metrics?.sessions ?? 0), + pageviews: acc.pageviews + (site.metrics?.pageviews ?? 0), + bounceRate: acc.bounceRate + (site.metrics?.bounceRate ?? 0), + sessionDuration: acc.sessionDuration + (site.metrics?.sessionDuration ?? 0), + pagesPerSession: acc.pagesPerSession + (site.metrics?.pagesPerSession ?? 0), + usersChange: acc.usersChange + (site.metrics?.usersChange ?? 0), + sessionsChange: acc.sessionsChange + (site.metrics?.sessionsChange ?? 0), + pageviewsChange: acc.pageviewsChange + (site.metrics?.pageviewsChange ?? 0), + count: acc.count + 1, + }), + { + users: 0, + sessions: 0, + pageviews: 0, + bounceRate: 0, + sessionDuration: 0, + pagesPerSession: 0, + usersChange: 0, + sessionsChange: 0, + pageviewsChange: 0, + count: 0, + } + ); + + return { + totalSites: sites.length, + totalUsers: totals.users, + totalSessions: totals.sessions, + totalPageviews: totals.pageviews, + avgBounceRate: totals.bounceRate / totals.count, + avgSessionDuration: totals.sessionDuration / totals.count, + avgPagesPerSession: totals.pagesPerSession / totals.count, + usersChange: totals.usersChange / totals.count, + sessionsChange: totals.sessionsChange / totals.count, + pageviewsChange: totals.pageviewsChange / totals.count, + }; + }, [sites]); + + const StatCard = ({ + icon: Icon, + label, + value, + change, + suffix = "", + }: { + icon: any; + label: string; + value: string | number; + change?: number; + suffix?: string; + }) => ( + +
+
+
+ + {label} +
+
+ {value} + {suffix && {suffix}} +
+ {change !== undefined && change !== 0 && ( +
+ {change > 0 ? ( + + ) : ( + + )} + 0 ? "text-green-600" : "text-red-600"}`}> + {Math.abs(change).toFixed(1)}% vs previous period + +
+ )} +
+
+
+ ); + + if (isLoading) { + return null; + } + + return ( +
+ + + + + + +
+ ); +} diff --git a/server/Dockerfile b/server/Dockerfile index ac2495532..cdb714369 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -40,11 +40,13 @@ COPY --from=builder /app/server/src ./src COPY --from=builder /app/shared ./shared # Make the entrypoint executable -RUN chmod +x /docker-entrypoint.sh +# Normalize line endings (fixes CRLF on Windows checkouts) and make executable +RUN sed -i 's/\r$//' /docker-entrypoint.sh \ + && chmod +x /docker-entrypoint.sh # Expose the API port EXPOSE 3001 # Use our custom entrypoint script ENTRYPOINT ["/docker-entrypoint.sh"] -CMD ["node", "dist/index.js"] \ No newline at end of file +CMD ["node", "dist/index.js"] diff --git a/server/src/api/sites/getSitesFromOrg.ts b/server/src/api/sites/getSitesFromOrg.ts index 780aa5df0..b9bff359f 100644 --- a/server/src/api/sites/getSitesFromOrg.ts +++ b/server/src/api/sites/getSitesFromOrg.ts @@ -13,11 +13,16 @@ export async function getSitesFromOrg( Params: { organizationId: string; }; + Querystring: { + includeMetrics?: string; + timePeriod?: string; // 24h, 7d, 30d + }; }>, res: FastifyReply ) { try { const { organizationId } = req.params; + const { includeMetrics, timePeriod = "24h" } = req.query; const session = await getSessionFromReq(req); const userId = session?.user.id; @@ -42,19 +47,34 @@ export async function getSitesFromOrg( return res.status(403).send({ error: "Access denied to this organization" }); } - // Query session counts for the sites + // Determine time intervals based on period + let currentInterval = "1 DAY"; + let previousInterval = "2 DAY"; + + if (timePeriod === "7d") { + currentInterval = "7 DAY"; + previousInterval = "14 DAY"; + } else if (timePeriod === "30d") { + currentInterval = "30 DAY"; + previousInterval = "60 DAY"; + } + + // Query session counts and comprehensive metrics for the sites const sessionCountMap = new Map(); + const metricsMap = new Map(); + const previousMetricsMap = new Map(); if (sitesData.length > 0) { const siteIds = sitesData.map(site => site.siteId); + // Basic session counts query (always included) const sessionCountsResult = await clickhouse.query({ query: ` - SELECT - site_id, - uniqExact(session_id) AS total_sessions - FROM events - WHERE timestamp >= now() - INTERVAL 1 DAY + SELECT + site_id, + uniqExact(session_id) AS total_sessions + FROM events + WHERE timestamp >= now() - INTERVAL ${currentInterval} AND site_id IN (${siteIds.join(",")}) GROUP BY site_id `, @@ -69,6 +89,138 @@ export async function getSitesFromOrg( } }); } + + // If detailed metrics are requested, fetch comprehensive overview data + if (includeMetrics === "true") { + // Current period metrics + const metricsResult = await clickhouse.query({ + query: ` + WITH + AllSessionPageviews AS ( + SELECT + site_id, + session_id, + COUNT(CASE WHEN type = 'pageview' THEN 1 END) AS total_pageviews_in_session + FROM events + WHERE + site_id IN (${siteIds.join(",")}) + AND timestamp >= now() - INTERVAL ${currentInterval} + GROUP BY site_id, session_id + ), + SessionStats AS ( + SELECT + site_id, + session_id, + MIN(timestamp) AS start_time, + MAX(timestamp) AS end_time, + total_pageviews_in_session + FROM events e + LEFT JOIN AllSessionPageviews asp USING (site_id, session_id) + WHERE + site_id IN (${siteIds.join(",")}) + AND timestamp >= now() - INTERVAL ${currentInterval} + GROUP BY site_id, session_id, total_pageviews_in_session + ) + SELECT + site_id, + COUNT(DISTINCT session_id) AS sessions, + COUNT(DISTINCT CASE WHEN type = 'pageview' THEN session_id END) AS pageview_sessions, + uniqExact(user_id) AS users, + countIf(type = 'pageview') AS pageviews, + AVG(total_pageviews_in_session) AS pages_per_session, + sumIf(1, total_pageviews_in_session = 1) / COUNT(DISTINCT session_id) * 100 AS bounce_rate, + AVG(end_time - start_time) AS session_duration + FROM events e + LEFT JOIN SessionStats ss USING (site_id, session_id) + WHERE + site_id IN (${siteIds.join(",")}) + AND timestamp >= now() - INTERVAL ${currentInterval} + GROUP BY site_id + `, + format: "JSONEachRow", + }); + const metrics = await processResults(metricsResult); + + if (Array.isArray(metrics)) { + metrics.forEach((row: any) => { + if (row && typeof row.site_id === "number") { + metricsMap.set(Number(row.site_id), { + users: Number(row.users) || 0, + pageviews: Number(row.pageviews) || 0, + bounceRate: Number(row.bounce_rate) || 0, + sessionDuration: Number(row.session_duration) || 0, + pagesPerSession: Number(row.pages_per_session) || 0, + }); + } + }); + } + + // Previous period metrics for comparison + const previousMetricsResult = await clickhouse.query({ + query: ` + WITH + AllSessionPageviews AS ( + SELECT + site_id, + session_id, + COUNT(CASE WHEN type = 'pageview' THEN 1 END) AS total_pageviews_in_session + FROM events + WHERE + site_id IN (${siteIds.join(",")}) + AND timestamp >= now() - INTERVAL ${previousInterval} + AND timestamp < now() - INTERVAL ${currentInterval} + GROUP BY site_id, session_id + ), + SessionStats AS ( + SELECT + site_id, + session_id, + MIN(timestamp) AS start_time, + MAX(timestamp) AS end_time, + total_pageviews_in_session + FROM events e + LEFT JOIN AllSessionPageviews asp USING (site_id, session_id) + WHERE + site_id IN (${siteIds.join(",")}) + AND timestamp >= now() - INTERVAL ${previousInterval} + AND timestamp < now() - INTERVAL ${currentInterval} + GROUP BY site_id, session_id, total_pageviews_in_session + ) + SELECT + site_id, + COUNT(DISTINCT session_id) AS sessions, + uniqExact(user_id) AS users, + countIf(type = 'pageview') AS pageviews, + AVG(total_pageviews_in_session) AS pages_per_session, + sumIf(1, total_pageviews_in_session = 1) / COUNT(DISTINCT session_id) * 100 AS bounce_rate, + AVG(end_time - start_time) AS session_duration + FROM events e + LEFT JOIN SessionStats ss USING (site_id, session_id) + WHERE + site_id IN (${siteIds.join(",")}) + AND timestamp >= now() - INTERVAL ${previousInterval} + AND timestamp < now() - INTERVAL ${currentInterval} + GROUP BY site_id + `, + format: "JSONEachRow", + }); + const previousMetrics = await processResults(previousMetricsResult); + + if (Array.isArray(previousMetrics)) { + previousMetrics.forEach((row: any) => { + if (row && typeof row.site_id === "number") { + previousMetricsMap.set(Number(row.site_id), { + users: Number(row.users) || 0, + pageviews: Number(row.pageviews) || 0, + sessions: Number(row.sessions) || 0, + bounceRate: Number(row.bounce_rate) || 0, + sessionDuration: Number(row.session_duration) || 0, + pagesPerSession: Number(row.pages_per_session) || 0, + }); + } + }); + } + } } // Get subscription info @@ -85,12 +237,53 @@ export async function getSitesFromOrg( eventLimit = subscription?.eventLimit || DEFAULT_EVENT_LIMIT; } - // Enhance sites data with session counts and subscription info - const enhancedSitesData = sitesData.map(site => ({ - ...site, - sessionsLast24Hours: sessionCountMap.get(site.siteId) || 0, - isOwner: memberCheck[0]?.role !== "member", - })); + // Helper function to calculate percentage change + const calculateChange = (current: number, previous: number): number => { + if (previous === 0) return current > 0 ? 100 : 0; + return ((current - previous) / previous) * 100; + }; + + // Enhance sites data with session counts and optional metrics + const enhancedSitesData = sitesData.map(site => { + const siteId = site.siteId; + const sessions = sessionCountMap.get(siteId) || 0; + const currentMetrics = metricsMap.get(siteId); + const previousMetrics = previousMetricsMap.get(siteId); + + const baseData = { + ...site, + sessionsLast24Hours: sessions, + isOwner: memberCheck[0]?.role !== "member", + }; + + // Only include metrics if requested + if (includeMetrics === "true" && currentMetrics) { + return { + ...baseData, + metrics: { + users: currentMetrics.users, + pageviews: currentMetrics.pageviews, + bounceRate: currentMetrics.bounceRate, + sessionDuration: currentMetrics.sessionDuration, + pagesPerSession: currentMetrics.pagesPerSession, + sessions: sessions, + // Calculate changes compared to previous period + usersChange: previousMetrics ? calculateChange(currentMetrics.users, previousMetrics.users) : 0, + pageviewsChange: previousMetrics ? calculateChange(currentMetrics.pageviews, previousMetrics.pageviews) : 0, + sessionsChange: previousMetrics ? calculateChange(sessions, previousMetrics.sessions) : 0, + bounceRateChange: previousMetrics ? calculateChange(currentMetrics.bounceRate, previousMetrics.bounceRate) : 0, + sessionDurationChange: previousMetrics + ? calculateChange(currentMetrics.sessionDuration, previousMetrics.sessionDuration) + : 0, + pagesPerSessionChange: previousMetrics + ? calculateChange(currentMetrics.pagesPerSession, previousMetrics.pagesPerSession) + : 0, + }, + }; + } + + return baseData; + }); // Sort by sessions descending enhancedSitesData.sort((a, b) => b.sessionsLast24Hours - a.sessionsLast24Hours);