aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/settings/layout.tsx6
-rw-r--r--apps/web/app/settings/stats/page.tsx495
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json3
-rw-r--r--packages/shared/types/users.ts46
-rw-r--r--packages/trpc/routers/users.test.ts342
-rw-r--r--packages/trpc/routers/users.ts211
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