Spaces:
Running
Running
| import { | |
| Badge, | |
| Card, | |
| Center, | |
| Group, | |
| Progress, | |
| SimpleGrid, | |
| Stack, | |
| Text, | |
| ThemeIcon, | |
| Title, | |
| } from "@mantine/core"; | |
| import { IconSearch } from "@tabler/icons-react"; | |
| import { useMemo } from "react"; | |
| import { useSearchHistory } from "../../hooks/useSearchHistory"; | |
| import type { SearchEntry } from "../../modules/history"; | |
| import { formatRelativeTime } from "../../modules/stringFormatters"; | |
| interface SearchStatsProps { | |
| period?: "today" | "week" | "month" | "all"; | |
| compact?: boolean; | |
| } | |
| interface StatsData { | |
| totalSearches: number; | |
| avgPerDay: number; | |
| mostActiveHour: number; | |
| topSources: { source: string; count: number; percentage: number }[]; | |
| recentActivity: SearchEntry[]; | |
| searchTrends: { date: string; count: number }[]; | |
| } | |
| export default function SearchStats({ | |
| period = "week", | |
| compact = false, | |
| }: SearchStatsProps) { | |
| const { recentSearches, isLoading } = useSearchHistory({ limit: 1000 }); | |
| const stats = useMemo((): StatsData => { | |
| if (!recentSearches.length) { | |
| return { | |
| totalSearches: 0, | |
| avgPerDay: 0, | |
| mostActiveHour: 0, | |
| topSources: [], | |
| recentActivity: [], | |
| searchTrends: [], | |
| }; | |
| } | |
| const now = new Date(); | |
| const filterDate = new Date(); | |
| switch (period) { | |
| case "today": | |
| filterDate.setHours(0, 0, 0, 0); | |
| break; | |
| case "week": | |
| filterDate.setDate(now.getDate() - 7); | |
| break; | |
| case "month": | |
| filterDate.setDate(now.getDate() - 30); | |
| break; | |
| default: | |
| filterDate.setFullYear(2000); | |
| } | |
| const filteredSearches = recentSearches.filter( | |
| (search) => search.timestamp >= filterDate.getTime(), | |
| ); | |
| const totalSearches = filteredSearches.length; | |
| const daysDiff = Math.max( | |
| 1, | |
| Math.ceil((now.getTime() - filterDate.getTime()) / (1000 * 60 * 60 * 24)), | |
| ); | |
| const avgPerDay = Math.round(totalSearches / daysDiff); | |
| const hourCounts = new Array(24).fill(0); | |
| filteredSearches.forEach((search) => { | |
| const hour = new Date(search.timestamp).getHours(); | |
| hourCounts[hour]++; | |
| }); | |
| const mostActiveHour = hourCounts.indexOf(Math.max(...hourCounts)); | |
| const sourceCounts = filteredSearches.reduce( | |
| (acc, search) => { | |
| if (compact && search.source.toLowerCase() === "user") { | |
| return acc; | |
| } | |
| acc[search.source] = (acc[search.source] || 0) + 1; | |
| return acc; | |
| }, | |
| {} as Record<string, number>, | |
| ); | |
| const sourcesTotal = Object.values(sourceCounts).reduce( | |
| (sum, n) => sum + n, | |
| 0, | |
| ); | |
| const topSources = Object.entries(sourceCounts) | |
| .map(([source, count]) => ({ | |
| source: source.charAt(0).toUpperCase() + source.slice(1), | |
| count, | |
| percentage: | |
| sourcesTotal > 0 ? Math.round((count / sourcesTotal) * 100) : 0, | |
| })) | |
| .sort((a, b) => b.count - a.count); | |
| const recentActivity = filteredSearches | |
| .sort((a, b) => b.timestamp - a.timestamp) | |
| .slice(0, 10); | |
| const trends = new Map<string, number>(); | |
| filteredSearches.forEach((search) => { | |
| const date = new Date(search.timestamp).toISOString().split("T")[0]; | |
| trends.set(date, (trends.get(date) || 0) + 1); | |
| }); | |
| const searchTrends = Array.from(trends.entries()) | |
| .map(([date, count]) => ({ date, count })) | |
| .sort((a, b) => a.date.localeCompare(b.date)); | |
| return { | |
| totalSearches, | |
| avgPerDay, | |
| mostActiveHour, | |
| topSources, | |
| recentActivity, | |
| searchTrends, | |
| }; | |
| }, [recentSearches, period, compact]); | |
| const getSourceColor = (source: string) => { | |
| const colors = { | |
| User: "blue", | |
| Followup: "green", | |
| Suggestion: "orange", | |
| }; | |
| return colors[source as keyof typeof colors] || "gray"; | |
| }; | |
| const formatHour = (hour: number) => { | |
| const period = hour >= 12 ? "PM" : "AM"; | |
| const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; | |
| return `${displayHour}:00 ${period}`; | |
| }; | |
| if (isLoading) { | |
| return ( | |
| <Card withBorder> | |
| <Center h={200}> | |
| <Text c="dimmed">Loading analytics...</Text> | |
| </Center> | |
| </Card> | |
| ); | |
| } | |
| if (stats.totalSearches === 0) { | |
| return ( | |
| <Card withBorder> | |
| <Center h={200}> | |
| <Stack align="center" gap="xs"> | |
| <ThemeIcon size="xl" variant="light" color="gray"> | |
| <IconSearch size={24} /> | |
| </ThemeIcon> | |
| <Text c="dimmed">No search data available</Text> | |
| <Text size="sm" c="dimmed"> | |
| Start searching to see analytics | |
| </Text> | |
| </Stack> | |
| </Center> | |
| </Card> | |
| ); | |
| } | |
| function MetricCard({ | |
| title, | |
| value, | |
| }: { | |
| title: string; | |
| value: string | number; | |
| }) { | |
| return ( | |
| <Card withBorder p={compact ? "sm" : "md"}> | |
| <Stack gap="xs" align="flex-start"> | |
| <Text size="xs" tt="uppercase" fw={700} c="dimmed"> | |
| {title} | |
| </Text> | |
| <Text fw={700} size={compact ? "lg" : "xl"}> | |
| {value} | |
| </Text> | |
| </Stack> | |
| </Card> | |
| ); | |
| } | |
| return ( | |
| <Stack gap={compact ? "sm" : "md"}> | |
| <SimpleGrid cols={compact ? 2 : { base: 1, sm: 2, lg: 4 }}> | |
| <MetricCard | |
| title="Total Searches" | |
| value={stats.totalSearches.toLocaleString()} | |
| /> | |
| <MetricCard title="Daily Average" value={stats.avgPerDay} /> | |
| <MetricCard | |
| title="Most Active Hour" | |
| value={formatHour(stats.mostActiveHour)} | |
| /> | |
| <MetricCard | |
| title="Last Search" | |
| value={ | |
| stats.recentActivity.length > 0 | |
| ? formatRelativeTime(stats.recentActivity[0].timestamp) | |
| : "Never" | |
| } | |
| /> | |
| </SimpleGrid> | |
| <SimpleGrid cols={compact ? 1 : { base: 1, md: 2 }}> | |
| {!(compact && stats.topSources.length === 0) && ( | |
| <Card withBorder> | |
| <Card.Section p={compact ? "sm" : "md"}> | |
| <Title order={compact ? 5 : 4} mb={compact ? "xs" : "md"}> | |
| Search Sources | |
| </Title> | |
| {stats.topSources.length > 0 ? ( | |
| <Stack gap="xs"> | |
| {stats.topSources.map((source) => ( | |
| <Group key={source.source} justify="space-between"> | |
| <Group gap="xs"> | |
| <Badge | |
| color={getSourceColor(source.source)} | |
| variant="dot" | |
| size="sm" | |
| > | |
| {source.source} | |
| </Badge> | |
| <Text size={compact ? "xs" : "sm"}> | |
| {source.count} searches | |
| </Text> | |
| </Group> | |
| <Group gap="xs"> | |
| <Progress | |
| value={source.percentage} | |
| size="sm" | |
| w={compact ? 40 : 60} | |
| color={getSourceColor(source.source)} | |
| /> | |
| <Text size="xs" c="dimmed" w={30}> | |
| {source.percentage}% | |
| </Text> | |
| </Group> | |
| </Group> | |
| ))} | |
| </Stack> | |
| ) : ( | |
| <Text c="dimmed" size="sm"> | |
| No data available | |
| </Text> | |
| )} | |
| </Card.Section> | |
| </Card> | |
| )} | |
| </SimpleGrid> | |
| {stats.searchTrends.length > 1 && ( | |
| <Card withBorder> | |
| <Card.Section p="md"> | |
| <Title order={4} mb="md"> | |
| Search Trends | |
| </Title> | |
| <Text size="sm" c="dimmed"> | |
| Daily search activity over the selected period | |
| </Text> | |
| <Stack gap="xs" mt="md"> | |
| {stats.searchTrends.slice(-7).map((trend) => { | |
| const maxCount = Math.max( | |
| ...stats.searchTrends.map((t) => t.count), | |
| ); | |
| const percentage = | |
| maxCount > 0 ? (trend.count / maxCount) * 100 : 0; | |
| return ( | |
| <Group key={trend.date} justify="space-between"> | |
| <Text size="xs" c="dimmed" w={80}> | |
| {new Date(trend.date).toLocaleDateString("en", { | |
| month: "short", | |
| day: "numeric", | |
| })} | |
| </Text> | |
| <Group gap="xs" style={{ flex: 1 }}> | |
| <Progress | |
| value={percentage} | |
| size="sm" | |
| style={{ flex: 1 }} | |
| color="blue" | |
| /> | |
| <Text size="xs" w={20}> | |
| {trend.count} | |
| </Text> | |
| </Group> | |
| </Group> | |
| ); | |
| })} | |
| </Stack> | |
| </Card.Section> | |
| </Card> | |
| )} | |
| </Stack> | |
| ); | |
| } | |