diff options
| -rw-r--r-- | apps/web/app/settings/layout.tsx | 6 | ||||
| -rw-r--r-- | apps/web/app/settings/stats/page.tsx | 495 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 3 | ||||
| -rw-r--r-- | packages/shared/types/users.ts | 46 | ||||
| -rw-r--r-- | packages/trpc/routers/users.test.ts | 342 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 211 |
6 files changed, 1102 insertions, 1 deletions
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, @@ -35,6 +36,11 @@ const settingsSidebarItems = ( path: "/settings/info", }, { + name: "Usage Statistics", + icon: <BarChart3 size={18} />, + path: "/settings/stats", + }, + { name: t("settings.ai.ai_settings"), icon: <Sparkles size={18} />, path: "/settings/ai", 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 ( + <div className="space-y-2"> + {data.map((value, index) => ( + <div key={index} className="flex items-center gap-3"> + <div className="w-12 text-right text-xs text-muted-foreground"> + {labels[index]} + </div> + <div className="relative h-2 flex-1 overflow-hidden rounded-full bg-muted"> + <div + className="h-full rounded-full bg-primary transition-all duration-300" + style={{ + width: `${maxValue > 0 ? (value / maxValue) * 100 : 0}%`, + }} + /> + </div> + <div className="w-8 text-right text-xs text-muted-foreground"> + {value} + </div> + </div> + ))} + </div> + ); +} + +function StatCard({ + title, + value, + icon, + description, +}: { + title: string; + value: string | number; + icon: React.ReactNode; + description?: string; +}) { + return ( + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">{title}</CardTitle> + {icon} + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{value}</div> + {description && ( + <p className="text-xs text-muted-foreground">{description}</p> + )} + </CardContent> + </Card> + ); +} + +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 ( + <div className="space-y-6"> + <div> + <h1 className="text-3xl font-bold">Usage Statistics</h1> + <p className="text-muted-foreground"> + Insights into your bookmarking habits and collection + </p> + </div> + + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> + {Array.from({ length: 8 }).map((_, i) => ( + <Card key={i}> + <CardHeader className="space-y-0 pb-2"> + <Skeleton className="h-4 w-24" /> + </CardHeader> + <CardContent> + <Skeleton className="mb-2 h-8 w-16" /> + <Skeleton className="h-3 w-32" /> + </CardContent> + </Card> + ))} + </div> + </div> + ); + } + + if (!stats) { + return ( + <div className="flex h-64 items-center justify-center"> + <p className="text-muted-foreground">Failed to load statistics</p> + </div> + ); + } + + return ( + <div className="space-y-6"> + <div> + <h1 className="text-3xl font-bold">Usage Statistics</h1> + <p className="text-muted-foreground"> + Insights into your bookmarking habits and collection + </p> + </div> + + {/* Overview Stats */} + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> + <StatCard + title="Total Bookmarks" + value={formatNumber(stats.numBookmarks)} + icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} + description="All saved items" + /> + <StatCard + title="Favorites" + value={formatNumber(stats.numFavorites)} + icon={<Heart className="h-4 w-4 text-muted-foreground" />} + description="Starred bookmarks" + /> + <StatCard + title="Archived" + value={formatNumber(stats.numArchived)} + icon={<Archive className="h-4 w-4 text-muted-foreground" />} + description="Archived items" + /> + <StatCard + title="Tags" + value={formatNumber(stats.numTags)} + icon={<Hash className="h-4 w-4 text-muted-foreground" />} + description="Unique tags created" + /> + <StatCard + title="Lists" + value={formatNumber(stats.numLists)} + icon={<List className="h-4 w-4 text-muted-foreground" />} + description="Bookmark collections" + /> + <StatCard + title="Highlights" + value={formatNumber(stats.numHighlights)} + icon={<Highlighter className="h-4 w-4 text-muted-foreground" />} + description="Text highlights" + /> + <StatCard + title="Storage Used" + value={formatBytes(stats.totalAssetSize)} + icon={<Database className="h-4 w-4 text-muted-foreground" />} + description="Total asset storage" + /> + <StatCard + title="This Month" + value={formatNumber(stats.bookmarkingActivity.thisMonth)} + icon={<TrendingUp className="h-4 w-4 text-muted-foreground" />} + description="Bookmarks added" + /> + </div> + + <div className="grid gap-6 md:grid-cols-2"> + {/* Bookmark Types */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <BarChart3 className="h-5 w-5" /> + Bookmark Types + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Link className="h-4 w-4 text-blue-500" /> + <span className="text-sm">Links</span> + </div> + <span className="text-sm font-medium"> + {stats.bookmarksByType.link} + </span> + </div> + <Progress + value={ + stats.numBookmarks > 0 + ? (stats.bookmarksByType.link / stats.numBookmarks) * 100 + : 0 + } + className="h-2" + /> + </div> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-green-500" /> + <span className="text-sm">Text Notes</span> + </div> + <span className="text-sm font-medium"> + {stats.bookmarksByType.text} + </span> + </div> + <Progress + value={ + stats.numBookmarks > 0 + ? (stats.bookmarksByType.text / stats.numBookmarks) * 100 + : 0 + } + className="h-2" + /> + </div> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Image className="h-4 w-4 text-purple-500" /> + <span className="text-sm">Assets</span> + </div> + <span className="text-sm font-medium"> + {stats.bookmarksByType.asset} + </span> + </div> + <Progress + value={ + stats.numBookmarks > 0 + ? (stats.bookmarksByType.asset / stats.numBookmarks) * 100 + : 0 + } + className="h-2" + /> + </div> + </CardContent> + </Card> + + {/* Recent Activity */} + <Card className="flex flex-col"> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Clock className="h-5 w-5" /> + Recent Activity + </CardTitle> + </CardHeader> + <CardContent className="flex flex-1 items-center"> + <div className="grid w-full grid-cols-3 gap-4 text-center"> + <div> + <div className="text-2xl font-bold text-green-600"> + {stats.bookmarkingActivity.thisWeek} + </div> + <div className="text-xs text-muted-foreground">This Week</div> + </div> + <div> + <div className="text-2xl font-bold text-blue-600"> + {stats.bookmarkingActivity.thisMonth} + </div> + <div className="text-xs text-muted-foreground">This Month</div> + </div> + <div> + <div className="text-2xl font-bold text-purple-600"> + {stats.bookmarkingActivity.thisYear} + </div> + <div className="text-xs text-muted-foreground">This Year</div> + </div> + </div> + </CardContent> + </Card> + + {/* Top Domains */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Globe className="h-5 w-5" /> + Top Domains + </CardTitle> + </CardHeader> + <CardContent> + {stats.topDomains.length > 0 ? ( + <div className="space-y-3"> + {stats.topDomains + .slice(0, 8) + .map( + ( + domain: { domain: string; count: number }, + index: number, + ) => ( + <div + key={domain.domain} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium"> + {index + 1} + </div> + <span + className="max-w-[200px] truncate text-sm" + title={domain.domain} + > + {domain.domain} + </span> + </div> + <Badge variant="secondary">{domain.count}</Badge> + </div> + ), + )} + </div> + ) : ( + <p className="text-sm text-muted-foreground">No domains found</p> + )} + </CardContent> + </Card> + + {/* Top Tags */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Hash className="h-5 w-5" /> + Most Used Tags + </CardTitle> + </CardHeader> + <CardContent> + {stats.tagUsage.length > 0 ? ( + <div className="space-y-3"> + {stats.tagUsage + .slice(0, 8) + .map( + (tag: { name: string; count: number }, index: number) => ( + <div + key={tag.name} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium"> + {index + 1} + </div> + <span + className="max-w-[200px] truncate text-sm" + title={tag.name} + > + {tag.name} + </span> + </div> + <Badge variant="secondary">{tag.count}</Badge> + </div> + ), + )} + </div> + ) : ( + <p className="text-sm text-muted-foreground">No tags found</p> + )} + </CardContent> + </Card> + </div> + + {/* Activity Patterns */} + <div className="grid gap-6 md:grid-cols-2"> + {/* Hourly Activity */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Clock className="h-5 w-5" /> + Activity by Hour + </CardTitle> + </CardHeader> + <CardContent> + <SimpleBarChart + data={stats.bookmarkingActivity.byHour.map( + (h: { hour: number; count: number }) => h.count, + )} + maxValue={maxHourlyActivity} + labels={hourLabels} + /> + </CardContent> + </Card> + + {/* Daily Activity */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <BarChart3 className="h-5 w-5" /> + Activity by Day + </CardTitle> + </CardHeader> + <CardContent> + <SimpleBarChart + data={stats.bookmarkingActivity.byDayOfWeek.map( + (d: { day: number; count: number }) => d.count, + )} + maxValue={maxDailyActivity} + labels={dayNames} + /> + </CardContent> + </Card> + </div> + + {/* Asset Storage */} + {stats.assetsByType.length > 0 && ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Database className="h-5 w-5" /> + Storage Breakdown + </CardTitle> + </CardHeader> + <CardContent> + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> + {stats.assetsByType.map( + (asset: { type: string; count: number; totalSize: number }) => ( + <div key={asset.type} className="space-y-2"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium capitalize"> + {asset.type.replace(/([A-Z])/g, " $1").trim()} + </span> + <Badge variant="outline">{asset.count}</Badge> + </div> + <div className="text-xs text-muted-foreground"> + {formatBytes(asset.totalSize)} + </div> + <Progress + value={ + stats.totalAssetSize > 0 + ? (asset.totalSize / stats.totalAssetSize) * 100 + : 0 + } + className="h-2" + /> + </div> + ), + )} + </div> + </CardContent> + </Card> + )} + </div> + ); +} 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", diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts index 0abe13b1..65e73ff3 100644 --- a/packages/shared/types/users.ts +++ b/packages/shared/types/users.ts @@ -38,6 +38,52 @@ export const zUserStatsResponseSchema = z.object({ numTags: z.number(), numLists: z.number(), numHighlights: z.number(), + bookmarksByType: z.object({ + link: z.number(), + text: z.number(), + asset: z.number(), + }), + topDomains: z + .array( + z.object({ + domain: z.string(), + count: z.number(), + }), + ) + .max(10), + totalAssetSize: z.number(), + assetsByType: z.array( + z.object({ + type: z.string(), + count: z.number(), + totalSize: z.number(), + }), + ), + bookmarkingActivity: z.object({ + thisWeek: z.number(), + thisMonth: z.number(), + thisYear: z.number(), + byHour: z.array( + z.object({ + hour: z.number(), + count: z.number(), + }), + ), + byDayOfWeek: z.array( + z.object({ + day: z.number(), + count: z.number(), + }), + ), + }), + tagUsage: z + .array( + z.object({ + name: z.string(), + count: z.number(), + }), + ) + .max(10), }); export const zUserSettingsSchema = z.object({ diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts index 3fd939b1..2f75c8ce 100644 --- a/packages/trpc/routers/users.test.ts +++ b/packages/trpc/routers/users.test.ts @@ -1,5 +1,8 @@ import { assert, beforeEach, describe, expect, test } from "vitest"; +import { assets, AssetTypes, bookmarks } from "@karakeep/db/schema"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + import type { CustomTestContext } from "../testUtils"; import { defaultBeforeEach, getApiCaller } from "../testUtils"; @@ -131,4 +134,343 @@ describe("User Routes", () => { /No settings provided/, ); }); + + test<CustomTestContext>("user stats - empty user", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "stats@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id); + + const stats = await caller.users.stats(); + + // All stats should be zero for a new user + expect(stats.numBookmarks).toBe(0); + expect(stats.numFavorites).toBe(0); + expect(stats.numArchived).toBe(0); + expect(stats.numTags).toBe(0); + expect(stats.numLists).toBe(0); + expect(stats.numHighlights).toBe(0); + expect(stats.bookmarksByType).toEqual({ link: 0, text: 0, asset: 0 }); + expect(stats.topDomains).toEqual([]); + expect(stats.totalAssetSize).toBe(0); + expect(stats.assetsByType).toEqual([]); + expect(stats.tagUsage).toEqual([]); + expect(stats.bookmarkingActivity.thisWeek).toBe(0); + expect(stats.bookmarkingActivity.thisMonth).toBe(0); + expect(stats.bookmarkingActivity.thisYear).toBe(0); + expect(stats.bookmarkingActivity.byHour).toHaveLength(24); + expect(stats.bookmarkingActivity.byDayOfWeek).toHaveLength(7); + + // All hours and days should have 0 count + stats.bookmarkingActivity.byHour.forEach((hour, index) => { + expect(hour.hour).toBe(index); + expect(hour.count).toBe(0); + }); + stats.bookmarkingActivity.byDayOfWeek.forEach((day, index) => { + expect(day.day).toBe(index); + expect(day.count).toBe(0); + }); + }); + + test<CustomTestContext>("user stats - with data", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "statsdata@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id); + + // Create test bookmarks + const bookmark1 = await caller.bookmarks.createBookmark({ + url: "https://example.com/page1", + type: BookmarkTypes.LINK, + }); + + const bookmark2 = await caller.bookmarks.createBookmark({ + url: "https://google.com/search", + type: BookmarkTypes.LINK, + }); + + await caller.bookmarks.createBookmark({ + text: "Test note content", + type: BookmarkTypes.TEXT, + }); + + // Create tags + const tag1 = await caller.tags.create({ name: "tech" }); + const tag2 = await caller.tags.create({ name: "work" }); + + // Create lists + await caller.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + // Archive one bookmark + await caller.bookmarks.updateBookmark({ + bookmarkId: bookmark1.id, + archived: true, + }); + + // Favorite one bookmark + await caller.bookmarks.updateBookmark({ + bookmarkId: bookmark2.id, + favourited: true, + }); + + // Add tags to bookmarks + await caller.bookmarks.updateTags({ + bookmarkId: bookmark1.id, + attach: [{ tagId: tag1.id }], + detach: [], + }); + + await caller.bookmarks.updateTags({ + bookmarkId: bookmark2.id, + attach: [{ tagId: tag1.id }, { tagId: tag2.id }], + detach: [], + }); + + // Create highlights + await caller.highlights.create({ + bookmarkId: bookmark1.id, + startOffset: 0, + endOffset: 10, + text: "highlighted text", + note: "test note", + }); + + // Insert test assets directly into DB + await db.insert(assets).values([ + { + id: "asset1", + assetType: AssetTypes.LINK_SCREENSHOT, + size: 1024, + contentType: "image/png", + bookmarkId: bookmark1.id, + userId: user.id, + }, + { + id: "asset2", + assetType: AssetTypes.LINK_BANNER_IMAGE, + size: 2048, + contentType: "image/jpeg", + bookmarkId: bookmark2.id, + userId: user.id, + }, + ]); + + const stats = await caller.users.stats(); + + // Verify basic counts + expect(stats.numBookmarks).toBe(3); + expect(stats.numFavorites).toBe(1); + expect(stats.numArchived).toBe(1); + expect(stats.numTags).toBe(2); + expect(stats.numLists).toBe(1); + expect(stats.numHighlights).toBe(1); + + // Verify bookmark types + expect(stats.bookmarksByType.link).toBe(2); + expect(stats.bookmarksByType.text).toBe(1); + expect(stats.bookmarksByType.asset).toBe(0); + + // Verify top domains + expect(stats.topDomains).toHaveLength(2); + expect( + stats.topDomains.find((d) => d.domain === "example.com"), + ).toBeTruthy(); + expect( + stats.topDomains.find((d) => d.domain === "google.com"), + ).toBeTruthy(); + + // Verify asset stats + expect(stats.totalAssetSize).toBe(3072); // 1024 + 2048 + expect(stats.assetsByType).toHaveLength(2); + + const screenshotAsset = stats.assetsByType.find( + (a) => a.type === AssetTypes.LINK_SCREENSHOT, + ); + expect(screenshotAsset?.count).toBe(1); + expect(screenshotAsset?.totalSize).toBe(1024); + + const bannerAsset = stats.assetsByType.find( + (a) => a.type === AssetTypes.LINK_BANNER_IMAGE, + ); + expect(bannerAsset?.count).toBe(1); + expect(bannerAsset?.totalSize).toBe(2048); + + // Verify tag usage + expect(stats.tagUsage).toHaveLength(2); + const techTag = stats.tagUsage.find((t) => t.name === "tech"); + const workTag = stats.tagUsage.find((t) => t.name === "work"); + expect(techTag?.count).toBe(2); // Used in 2 bookmarks + expect(workTag?.count).toBe(1); // Used in 1 bookmark + + // Verify activity stats (should be > 0 since we just created bookmarks) + expect(stats.bookmarkingActivity.thisWeek).toBe(3); + expect(stats.bookmarkingActivity.thisMonth).toBe(3); + expect(stats.bookmarkingActivity.thisYear).toBe(3); + + // Verify hour/day arrays are properly structured + expect(stats.bookmarkingActivity.byHour).toHaveLength(24); + expect(stats.bookmarkingActivity.byDayOfWeek).toHaveLength(7); + }); + + test<CustomTestContext>("user stats - privacy isolation", async ({ + db, + unauthedAPICaller, + }) => { + // Create two users + const user1 = await unauthedAPICaller.users.create({ + name: "User 1", + email: "user1@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + + const user2 = await unauthedAPICaller.users.create({ + name: "User 2", + email: "user2@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + + const caller1 = getApiCaller(db, user1.id); + const caller2 = getApiCaller(db, user2.id); + + // User 1 creates some bookmarks + const bookmark1 = await caller1.bookmarks.createBookmark({ + url: "https://user1.com", + type: BookmarkTypes.LINK, + }); + + const tag1 = await caller1.tags.create({ name: "user1tag" }); + + // Attach tag to bookmark + await caller1.bookmarks.updateTags({ + bookmarkId: bookmark1.id, + attach: [{ tagId: tag1.id }], + detach: [], + }); + + // User 2 creates different bookmarks + const bookmark2 = await caller2.bookmarks.createBookmark({ + url: "https://user2.com", + type: BookmarkTypes.LINK, + }); + + const tag2 = await caller2.tags.create({ name: "user2tag" }); + + // Attach tag to bookmark + await caller2.bookmarks.updateTags({ + bookmarkId: bookmark2.id, + attach: [{ tagId: tag2.id }], + detach: [], + }); + + // Get stats for both users + const stats1 = await caller1.users.stats(); + const stats2 = await caller2.users.stats(); + + // Each user should only see their own data + expect(stats1.numBookmarks).toBe(1); + expect(stats1.numTags).toBe(1); + expect(stats1.topDomains[0]?.domain).toBe("user1.com"); + expect(stats1.tagUsage[0]?.name).toBe("user1tag"); + + expect(stats2.numBookmarks).toBe(1); + expect(stats2.numTags).toBe(1); + expect(stats2.topDomains[0]?.domain).toBe("user2.com"); + expect(stats2.tagUsage[0]?.name).toBe("user2tag"); + + // Users should not see each other's data + expect(stats1.topDomains.find((d) => d.domain === "user2.com")).toBeFalsy(); + expect(stats2.topDomains.find((d) => d.domain === "user1.com")).toBeFalsy(); + }); + + test<CustomTestContext>("user stats - activity time patterns", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "timepatterns@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id); + + // Create bookmarks with specific timestamps + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + // Insert bookmarks directly with specific timestamps + await db + .insert(bookmarks) + .values([ + { + userId: user.id, + type: BookmarkTypes.LINK, + createdAt: now, + archived: false, + favourited: false, + }, + { + userId: user.id, + type: BookmarkTypes.LINK, + createdAt: oneDayAgo, + archived: false, + favourited: false, + }, + { + userId: user.id, + type: BookmarkTypes.LINK, + createdAt: oneWeekAgo, + archived: false, + favourited: false, + }, + { + userId: user.id, + type: BookmarkTypes.LINK, + createdAt: oneMonthAgo, + archived: false, + favourited: false, + }, + ]) + .returning(); + + const stats = await caller.users.stats(); + + // Verify activity counts based on time periods + expect(stats.bookmarkingActivity.thisWeek).toBeGreaterThanOrEqual(2); // now + oneDayAgo + expect(stats.bookmarkingActivity.thisMonth).toBeGreaterThanOrEqual(3); // now + oneDayAgo + oneWeekAgo + expect(stats.bookmarkingActivity.thisYear).toBe(4); // All bookmarks + + // Verify that hour and day arrays have proper structure + expect( + stats.bookmarkingActivity.byHour.every( + (h) => typeof h.hour === "number" && h.hour >= 0 && h.hour <= 23, + ), + ).toBe(true); + + expect( + stats.bookmarkingActivity.byDayOfWeek.every( + (d) => typeof d.day === "number" && d.day >= 0 && d.day <= 6, + ), + ).toBe(true); + }); }); diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts index 33aac2b7..ea5e6944 100644 --- a/packages/trpc/routers/users.ts +++ b/packages/trpc/routers/users.ts @@ -1,14 +1,17 @@ import { TRPCError } from "@trpc/server"; -import { and, count, eq } from "drizzle-orm"; +import { and, count, desc, eq, gte, sql } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; import { SqliteError } from "@karakeep/db"; import { + assets, + bookmarkLinks, bookmarkLists, bookmarks, bookmarkTags, highlights, + tagsOnBookmarks, users, userSettings, } from "@karakeep/db/schema"; @@ -223,6 +226,11 @@ export const usersAppRouter = router({ stats: authedProcedure .output(zUserStatsResponseSchema) .query(async ({ ctx }) => { + const now = new Date(); + const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const yearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000); + const [ [{ numBookmarks }], [{ numFavorites }], @@ -230,7 +238,18 @@ export const usersAppRouter = router({ [{ numTags }], [{ numLists }], [{ numHighlights }], + bookmarksByType, + topDomains, + [{ totalAssetSize }], + assetsByType, + [{ thisWeek }], + [{ thisMonth }], + [{ thisYear }], + bookmarkingByHour, + bookmarkingByDay, + tagUsage, ] = await Promise.all([ + // Basic counts ctx.db .select({ numBookmarks: count() }) .from(bookmarks) @@ -265,7 +284,185 @@ export const usersAppRouter = router({ .select({ numHighlights: count() }) .from(highlights) .where(eq(highlights.userId, ctx.user.id)), + + // Bookmarks by type + ctx.db + .select({ + type: bookmarks.type, + count: count(), + }) + .from(bookmarks) + .where(eq(bookmarks.userId, ctx.user.id)) + .groupBy(bookmarks.type), + + // Top domains + ctx.db + .select({ + domain: sql<string>`CASE + WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN + CASE + WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN + SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1) + ELSE + SUBSTR(${bookmarkLinks.url}, 9) + END + WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN + CASE + WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN + SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1) + ELSE + SUBSTR(${bookmarkLinks.url}, 8) + END + ELSE + CASE + WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN + SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1) + ELSE + ${bookmarkLinks.url} + END + END`, + count: count(), + }) + .from(bookmarkLinks) + .innerJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id)) + .where(eq(bookmarks.userId, ctx.user.id)) + .groupBy( + sql`CASE + WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN + CASE + WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN + SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1) + ELSE + SUBSTR(${bookmarkLinks.url}, 9) + END + WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN + CASE + WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN + SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1) + ELSE + SUBSTR(${bookmarkLinks.url}, 8) + END + ELSE + CASE + WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN + SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1) + ELSE + ${bookmarkLinks.url} + END + END`, + ) + .orderBy(desc(count())) + .limit(10), + + // Total asset size + ctx.db + .select({ + totalAssetSize: sql<number>`COALESCE(SUM(${assets.size}), 0)`, + }) + .from(assets) + .where(eq(assets.userId, ctx.user.id)), + + // Assets by type + ctx.db + .select({ + type: assets.assetType, + count: count(), + totalSize: sql<number>`COALESCE(SUM(${assets.size}), 0)`, + }) + .from(assets) + .where(eq(assets.userId, ctx.user.id)) + .groupBy(assets.assetType), + + // Activity stats + ctx.db + .select({ thisWeek: count() }) + .from(bookmarks) + .where( + and( + eq(bookmarks.userId, ctx.user.id), + gte(bookmarks.createdAt, weekAgo), + ), + ), + ctx.db + .select({ thisMonth: count() }) + .from(bookmarks) + .where( + and( + eq(bookmarks.userId, ctx.user.id), + gte(bookmarks.createdAt, monthAgo), + ), + ), + ctx.db + .select({ thisYear: count() }) + .from(bookmarks) + .where( + and( + eq(bookmarks.userId, ctx.user.id), + gte(bookmarks.createdAt, yearAgo), + ), + ), + + // Bookmarking by hour (UTC time) + ctx.db + .select({ + hour: sql<number>`CAST(strftime('%H', datetime(${bookmarks.createdAt} / 1000, 'unixepoch')) AS INTEGER)`, + count: count(), + }) + .from(bookmarks) + .where(eq(bookmarks.userId, ctx.user.id)) + .groupBy( + sql`strftime('%H', datetime(${bookmarks.createdAt} / 1000, 'unixepoch'))`, + ), + + // Bookmarking by day of week (UTC time) + ctx.db + .select({ + day: sql<number>`CAST(strftime('%w', datetime(${bookmarks.createdAt} / 1000, 'unixepoch')) AS INTEGER)`, + count: count(), + }) + .from(bookmarks) + .where(eq(bookmarks.userId, ctx.user.id)) + .groupBy( + sql`strftime('%w', datetime(${bookmarks.createdAt} / 1000, 'unixepoch'))`, + ), + + // Tag usage + ctx.db + .select({ + name: bookmarkTags.name, + count: count(), + }) + .from(bookmarkTags) + .innerJoin( + tagsOnBookmarks, + eq(tagsOnBookmarks.tagId, bookmarkTags.id), + ) + .where(eq(bookmarkTags.userId, ctx.user.id)) + .groupBy(bookmarkTags.name) + .orderBy(desc(count())) + .limit(10), ]); + + // Process bookmarks by type + const bookmarkTypeMap = { link: 0, text: 0, asset: 0 }; + bookmarksByType.forEach((item) => { + if (item.type in bookmarkTypeMap) { + bookmarkTypeMap[item.type as keyof typeof bookmarkTypeMap] = + item.count; + } + }); + + // Fill missing hours and days with 0 + const hourlyActivity = Array.from({ length: 24 }, (_, i) => ({ + hour: i, + count: bookmarkingByHour.find((item) => item.hour === i)?.count || 0, + })); + + const dailyActivity = Array.from({ length: 7 }, (_, i) => ({ + day: i, + count: bookmarkingByDay.find((item) => item.day === i)?.count || 0, + })); + return { numBookmarks, numFavorites, @@ -273,6 +470,18 @@ export const usersAppRouter = router({ numTags, numLists, numHighlights, + bookmarksByType: bookmarkTypeMap, + topDomains: topDomains.filter((d) => d.domain && d.domain.length > 0), + totalAssetSize: totalAssetSize || 0, + assetsByType, + bookmarkingActivity: { + thisWeek: thisWeek || 0, + thisMonth: thisMonth || 0, + thisYear: thisYear || 0, + byHour: hourlyActivity, + byDayOfWeek: dailyActivity, + }, + tagUsage, }; }), settings: authedProcedure |
