From 47624547f8cb352426d597537c11e7a4550aa91e Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 6 Jul 2025 14:31:58 +0000 Subject: feat: Add new user stats page. Fixes #1523 --- apps/web/app/settings/layout.tsx | 6 + apps/web/app/settings/stats/page.tsx | 495 ++++++++++++++++++++++++++ apps/web/lib/i18n/locales/en/translation.json | 3 + 3 files changed, 504 insertions(+) create mode 100644 apps/web/app/settings/stats/page.tsx (limited to 'apps/web') diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index 1f7c5c12..4ff3719f 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -6,6 +6,7 @@ import { api } from "@/server/api/client"; import { TFunction } from "i18next"; import { ArrowLeft, + BarChart3, Download, GitBranch, Image, @@ -34,6 +35,11 @@ const settingsSidebarItems = ( icon: , path: "/settings/info", }, + { + name: "Usage Statistics", + icon: , + path: "/settings/stats", + }, { name: t("settings.ai.ai_settings"), icon: , diff --git a/apps/web/app/settings/stats/page.tsx b/apps/web/app/settings/stats/page.tsx new file mode 100644 index 00000000..15608a34 --- /dev/null +++ b/apps/web/app/settings/stats/page.tsx @@ -0,0 +1,495 @@ +"use client"; + +import { useMemo } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { api } from "@/lib/trpc"; +import { + Archive, + BarChart3, + BookOpen, + Clock, + Database, + FileText, + Globe, + Hash, + Heart, + Highlighter, + Image, + Link, + List, + TrendingUp, +} from "lucide-react"; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} + +function formatNumber(num: number): string { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + "M"; + } + if (num >= 1000) { + return (num / 1000).toFixed(1) + "K"; + } + return num.toString(); +} + +const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const hourLabels = Array.from({ length: 24 }, (_, i) => + i === 0 ? "12 AM" : i < 12 ? `${i} AM` : i === 12 ? "12 PM" : `${i - 12} PM`, +); + +function SimpleBarChart({ + data, + maxValue, + labels, +}: { + data: number[]; + maxValue: number; + labels: string[]; +}) { + return ( +
+ {data.map((value, index) => ( +
+
+ {labels[index]} +
+
+
0 ? (value / maxValue) * 100 : 0}%`, + }} + /> +
+
+ {value} +
+
+ ))} +
+ ); +} + +function StatCard({ + title, + value, + icon, + description, +}: { + title: string; + value: string | number; + icon: React.ReactNode; + description?: string; +}) { + return ( + + + {title} + {icon} + + +
{value}
+ {description && ( +

{description}

+ )} +
+
+ ); +} + +export default function StatsPage() { + const { data: stats, isLoading } = api.users.stats.useQuery(); + + const maxHourlyActivity = useMemo(() => { + if (!stats) return 0; + return Math.max( + ...stats.bookmarkingActivity.byHour.map( + (h: { hour: number; count: number }) => h.count, + ), + ); + }, [stats]); + + const maxDailyActivity = useMemo(() => { + if (!stats) return 0; + return Math.max( + ...stats.bookmarkingActivity.byDayOfWeek.map( + (d: { day: number; count: number }) => d.count, + ), + ); + }, [stats]); + + if (isLoading) { + return ( +
+
+

Usage Statistics

+

+ Insights into your bookmarking habits and collection +

+
+ +
+ {Array.from({ length: 8 }).map((_, i) => ( + + + + + + + + + + ))} +
+
+ ); + } + + if (!stats) { + return ( +
+

Failed to load statistics

+
+ ); + } + + return ( +
+
+

Usage Statistics

+

+ Insights into your bookmarking habits and collection +

+
+ + {/* Overview Stats */} +
+ } + description="All saved items" + /> + } + description="Starred bookmarks" + /> + } + description="Archived items" + /> + } + description="Unique tags created" + /> + } + description="Bookmark collections" + /> + } + description="Text highlights" + /> + } + description="Total asset storage" + /> + } + description="Bookmarks added" + /> +
+ +
+ {/* Bookmark Types */} + + + + + Bookmark Types + + + +
+
+
+ + Links +
+ + {stats.bookmarksByType.link} + +
+ 0 + ? (stats.bookmarksByType.link / stats.numBookmarks) * 100 + : 0 + } + className="h-2" + /> +
+
+
+
+ + Text Notes +
+ + {stats.bookmarksByType.text} + +
+ 0 + ? (stats.bookmarksByType.text / stats.numBookmarks) * 100 + : 0 + } + className="h-2" + /> +
+
+
+
+ + Assets +
+ + {stats.bookmarksByType.asset} + +
+ 0 + ? (stats.bookmarksByType.asset / stats.numBookmarks) * 100 + : 0 + } + className="h-2" + /> +
+
+
+ + {/* Recent Activity */} + + + + + Recent Activity + + + +
+
+
+ {stats.bookmarkingActivity.thisWeek} +
+
This Week
+
+
+
+ {stats.bookmarkingActivity.thisMonth} +
+
This Month
+
+
+
+ {stats.bookmarkingActivity.thisYear} +
+
This Year
+
+
+
+
+ + {/* Top Domains */} + + + + + Top Domains + + + + {stats.topDomains.length > 0 ? ( +
+ {stats.topDomains + .slice(0, 8) + .map( + ( + domain: { domain: string; count: number }, + index: number, + ) => ( +
+
+
+ {index + 1} +
+ + {domain.domain} + +
+ {domain.count} +
+ ), + )} +
+ ) : ( +

No domains found

+ )} +
+
+ + {/* Top Tags */} + + + + + Most Used Tags + + + + {stats.tagUsage.length > 0 ? ( +
+ {stats.tagUsage + .slice(0, 8) + .map( + (tag: { name: string; count: number }, index: number) => ( +
+
+
+ {index + 1} +
+ + {tag.name} + +
+ {tag.count} +
+ ), + )} +
+ ) : ( +

No tags found

+ )} +
+
+
+ + {/* Activity Patterns */} +
+ {/* Hourly Activity */} + + + + + Activity by Hour + + + + h.count, + )} + maxValue={maxHourlyActivity} + labels={hourLabels} + /> + + + + {/* Daily Activity */} + + + + + Activity by Day + + + + d.count, + )} + maxValue={maxDailyActivity} + labels={dayNames} + /> + + +
+ + {/* Asset Storage */} + {stats.assetsByType.length > 0 && ( + + + + + Storage Breakdown + + + +
+ {stats.assetsByType.map( + (asset: { type: string; count: number; totalSize: number }) => ( +
+
+ + {asset.type.replace(/([A-Z])/g, " $1").trim()} + + {asset.count} +
+
+ {formatBytes(asset.totalSize)} +
+ 0 + ? (asset.totalSize / stats.totalAssetSize) * 100 + : 0 + } + className="h-2" + /> +
+ ), + )} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index d33e8521..727a7538 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -115,6 +115,9 @@ } } }, + "stats": { + "usage_statistics": "Usage Statistics" + }, "ai": { "ai_settings": "AI Settings", "tagging_rules": "Tagging Rules", -- cgit v1.2.3-70-g09d2