aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-11-09 11:52:39 +0000
committerGitHub <noreply@github.com>2025-11-09 11:52:39 +0000
commit725b5218ea03d677cebbe62aadd2d227f8b6e214 (patch)
treea4abbbb7fc0b62d6dda468b4e3eac0c684266fb2
parent3083be0c9dc9ec0ded58eda937b83fbdf511f386 (diff)
downloadkarakeep-725b5218ea03d677cebbe62aadd2d227f8b6e214.tar.zst
feat: Add bookmark sources statistics section (#2110)
* feat: add bookmark sources statistics to usage stats page Add a new section to the usage statistics page that displays stats about bookmark sources (mobile, extension, web, API, CLI, etc). Changes: - Add bookmarksBySource field to user stats response schema - Implement backend query to fetch bookmarks grouped by source - Add new "Bookmark Sources" card to stats page UI - Add helper function to format source names for display * refactor: use stricter enum type for bookmark sources in stats API Replace generic string type with zBookmarkSourceSchema enum for better type safety and autocomplete. This ensures the API contract matches the database schema definition. Changes: - Import and use zBookmarkSourceSchema in user stats response - Define BookmarkSource type alias in frontend - Update formatSourceName to use stricter type and return non-nullable - Remove fallback case since all enum values are now handled * refactor: use shared BookmarkSource type and add i18n support - Replace local BookmarkSource type with canonical type from shared package using z.infer<typeof zBookmarkSourceSchema> - Add translation support for "Bookmark Sources" title and empty state - Add bookmark_sources.title and bookmark_sources.empty keys to English locale file This ensures type consistency across the codebase and prepares for future localization of the bookmark sources feature. * fix icons --------- Co-authored-by: Claude <noreply@anthropic.com>
-rw-r--r--apps/web/app/settings/stats/page.tsx90
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json4
-rw-r--r--packages/open-api/karakeep-openapi-spec.json32
-rw-r--r--packages/shared/types/users.ts8
-rw-r--r--packages/trpc/models/users.ts13
5 files changed, 146 insertions, 1 deletions
diff --git a/apps/web/app/settings/stats/page.tsx b/apps/web/app/settings/stats/page.tsx
index 599e5362..944d1c59 100644
--- a/apps/web/app/settings/stats/page.tsx
+++ b/apps/web/app/settings/stats/page.tsx
@@ -11,18 +11,30 @@ import {
Archive,
BarChart3,
BookOpen,
+ Chrome,
Clock,
+ Code,
Database,
FileText,
Globe,
Hash,
Heart,
+ HelpCircle,
Highlighter,
Image,
Link,
List,
+ Rss,
+ Smartphone,
TrendingUp,
+ Upload,
+ Zap,
} from "lucide-react";
+import { z } from "zod";
+
+import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks";
+
+type BookmarkSource = z.infer<typeof zBookmarkSourceSchema>;
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 Bytes";
@@ -47,6 +59,45 @@ const hourLabels = Array.from({ length: 24 }, (_, i) =>
i === 0 ? "12 AM" : i < 12 ? `${i} AM` : i === 12 ? "12 PM" : `${i - 12} PM`,
);
+function formatSourceName(source: BookmarkSource | null): string {
+ if (!source) return "Unknown";
+ const sourceMap: Record<BookmarkSource, string> = {
+ api: "API",
+ web: "Web",
+ extension: "Browser Extension",
+ cli: "CLI",
+ mobile: "Mobile App",
+ singlefile: "SingleFile",
+ rss: "RSS Feed",
+ import: "Import",
+ };
+ return sourceMap[source];
+}
+
+function getSourceIcon(source: BookmarkSource | null): React.ReactNode {
+ const iconProps = { className: "h-4 w-4 text-muted-foreground" };
+ switch (source) {
+ case "api":
+ return <Zap {...iconProps} />;
+ case "web":
+ return <Globe {...iconProps} />;
+ case "extension":
+ return <Chrome {...iconProps} />;
+ case "cli":
+ return <Code {...iconProps} />;
+ case "mobile":
+ return <Smartphone {...iconProps} />;
+ case "singlefile":
+ return <FileText {...iconProps} />;
+ case "rss":
+ return <Rss {...iconProps} />;
+ case "import":
+ return <Upload {...iconProps} />;
+ default:
+ return <HelpCircle {...iconProps} />;
+ }
+}
+
function SimpleBarChart({
data,
maxValue,
@@ -439,6 +490,45 @@ export default function StatsPage() {
)}
</CardContent>
</Card>
+
+ {/* Bookmark Sources */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Zap className="h-5 w-5" />
+ {t("settings.stats.bookmark_sources.title")}
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ {stats.bookmarksBySource.length > 0 ? (
+ <div className="space-y-3">
+ {stats.bookmarksBySource.map(
+ (source: {
+ source: BookmarkSource | null;
+ count: number;
+ }) => (
+ <div
+ key={source.source || "unknown"}
+ className="flex items-center justify-between"
+ >
+ <div className="flex items-center gap-2">
+ {getSourceIcon(source.source)}
+ <span className="max-w-[200px] truncate text-sm">
+ {formatSourceName(source.source)}
+ </span>
+ </div>
+ <Badge variant="secondary">{source.count}</Badge>
+ </div>
+ ),
+ )}
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ {t("settings.stats.bookmark_sources.empty")}
+ </p>
+ )}
+ </CardContent>
+ </Card>
</div>
{/* Activity Patterns */}
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index f582cd9b..4fc58be7 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -181,6 +181,10 @@
},
"storage_breakdown": {
"title": "Storage Breakdown"
+ },
+ "bookmark_sources": {
+ "title": "Bookmark Sources",
+ "empty": "No source data available"
}
},
"ai": {
diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json
index 2e791fbf..4f846cef 100644
--- a/packages/open-api/karakeep-openapi-spec.json
+++ b/packages/open-api/karakeep-openapi-spec.json
@@ -3397,6 +3397,35 @@
]
},
"maxItems": 10
+ },
+ "bookmarksBySource": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "source": {
+ "type": "string",
+ "nullable": true,
+ "enum": [
+ "api",
+ "web",
+ "cli",
+ "mobile",
+ "extension",
+ "singlefile",
+ "rss",
+ "import"
+ ]
+ },
+ "count": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "source",
+ "count"
+ ]
+ }
}
},
"required": [
@@ -3411,7 +3440,8 @@
"totalAssetSize",
"assetsByType",
"bookmarkingActivity",
- "tagUsage"
+ "tagUsage",
+ "bookmarksBySource"
]
}
}
diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts
index 758b757d..830fe87b 100644
--- a/packages/shared/types/users.ts
+++ b/packages/shared/types/users.ts
@@ -1,5 +1,7 @@
import { z } from "zod";
+import { zBookmarkSourceSchema } from "./bookmarks";
+
export const PASSWORD_MIN_LENGTH = 8;
export const PASSWORD_MAX_LENGTH = 100;
@@ -91,6 +93,12 @@ export const zUserStatsResponseSchema = z.object({
}),
)
.max(10),
+ bookmarksBySource: z.array(
+ z.object({
+ source: zBookmarkSourceSchema.nullable(),
+ count: z.number(),
+ }),
+ ),
});
export const zUserSettingsSchema = z.object({
diff --git a/packages/trpc/models/users.ts b/packages/trpc/models/users.ts
index a327e7db..7e6be7a5 100644
--- a/packages/trpc/models/users.ts
+++ b/packages/trpc/models/users.ts
@@ -506,6 +506,7 @@ export class User implements PrivacyAware {
[{ thisYear }],
bookmarkTimestamps,
tagUsage,
+ bookmarksBySource,
] = await Promise.all([
// Basic counts
this.ctx.db
@@ -677,6 +678,17 @@ export class User implements PrivacyAware {
.groupBy(bookmarkTags.name)
.orderBy(desc(count()))
.limit(10),
+
+ // Bookmarks by source
+ this.ctx.db
+ .select({
+ source: bookmarks.source,
+ count: count(),
+ })
+ .from(bookmarks)
+ .where(eq(bookmarks.userId, this.user.id))
+ .groupBy(bookmarks.source)
+ .orderBy(desc(count())),
]);
// Process bookmarks by type
@@ -735,6 +747,7 @@ export class User implements PrivacyAware {
byDayOfWeek: dailyActivity,
},
tagUsage,
+ bookmarksBySource,
};
}