From a0b4a26ad398137e13c35f3fe0dad99154537d91 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Tue, 30 Dec 2025 12:52:50 +0200 Subject: feat: 2025 wrapped (#2322) * feat: 2025 wrapped * don't add wrapped for new users --- apps/web/components/wrapped/WrappedContent.tsx | 390 +++++++++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 apps/web/components/wrapped/WrappedContent.tsx (limited to 'apps/web/components/wrapped/WrappedContent.tsx') diff --git a/apps/web/components/wrapped/WrappedContent.tsx b/apps/web/components/wrapped/WrappedContent.tsx new file mode 100644 index 00000000..261aadfd --- /dev/null +++ b/apps/web/components/wrapped/WrappedContent.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { forwardRef } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { + BookOpen, + Calendar, + Chrome, + Clock, + Code, + FileText, + Globe, + Hash, + Heart, + Highlighter, + Link, + Rss, + Smartphone, + Upload, + Zap, +} from "lucide-react"; +import { z } from "zod"; + +import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks"; +import { zWrappedStatsResponseSchema } from "@karakeep/shared/types/users"; + +type WrappedStats = z.infer; +type BookmarkSource = z.infer; + +interface WrappedContentProps { + stats: WrappedStats; + userName?: string; +} + +const dayNames = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; +const monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +function formatSourceName(source: BookmarkSource | null): string { + if (!source) return "Unknown"; + const sourceMap: Record = { + 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, className = "h-5 w-5") { + const iconProps = { className }; + switch (source) { + case "api": + return ; + case "web": + return ; + case "extension": + return ; + case "cli": + return ; + case "mobile": + return ; + case "singlefile": + return ; + case "rss": + return ; + case "import": + return ; + default: + return ; + } +} + +export const WrappedContent = forwardRef( + ({ stats, userName }, ref) => { + const maxMonthlyCount = Math.max( + ...stats.monthlyActivity.map((m) => m.count), + ); + + return ( +
+
+ {/* Header */} +
+
+

+ Your {stats.year} Wrapped +

+

+ A Year in Karakeep +

+ {userName && ( +

{userName}

+ )} +
+
+ +
+ +

You saved

+

+ {stats.totalBookmarks} +

+

+ {stats.totalBookmarks === 1 ? "item" : "items"} this year +

+
+ {/* First Bookmark */} + {stats.firstBookmark && ( + +
+
+ +

+ First Bookmark of {stats.year} +

+
+
+

+ {new Date( + stats.firstBookmark.createdAt, + ).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + })} +

+ {stats.firstBookmark.title && ( +

+ “{stats.firstBookmark.title}” +

+ )} +
+
+
+ )} + + {/* Activity + Peak */} + +

+ + Activity Highlights +

+
+ {stats.mostActiveDay && ( +
+

Most Active Day

+

+ {new Date(stats.mostActiveDay.date).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + }, + )} +

+

+ {stats.mostActiveDay.count}{" "} + {stats.mostActiveDay.count === 1 ? "save" : "saves"} +

+
+ )} +
+
+

Peak Hour

+

+ {stats.peakHour === 0 + ? "12 AM" + : stats.peakHour < 12 + ? `${stats.peakHour} AM` + : stats.peakHour === 12 + ? "12 PM" + : `${stats.peakHour - 12} PM`} +

+
+
+

Peak Day

+

+ {dayNames[stats.peakDayOfWeek]} +

+
+
+
+
+ + {/* Top Lists */} + {(stats.topDomains.length > 0 || stats.topTags.length > 0) && ( + +

+ Top Lists +

+
+ {stats.topDomains.length > 0 && ( +
+

+ + Sites +

+
+ {stats.topDomains.map((domain, index) => ( +
+
+
+ {index + 1} +
+ + {domain.domain} + +
+ + {domain.count} + +
+ ))} +
+
+ )} + {stats.topTags.length > 0 && ( +
+

+ + Tags +

+
+ {stats.topTags.map((tag, index) => ( +
+
+
+ {index + 1} +
+ {tag.name} +
+ + {tag.count} + +
+ ))} +
+
+ )} +
+
+ )} + + {/* Bookmarks by Source */} + {stats.bookmarksBySource.length > 0 && ( + +

+ How You Save +

+
+ {stats.bookmarksBySource.map((source) => ( +
+
+ {getSourceIcon(source.source, "h-4 w-4")} + {formatSourceName(source.source)} +
+ + {source.count} + +
+ ))} +
+
+ )} + + {/* Monthly Activity */} + +

+ + Your Year in Saves +

+
+ {stats.monthlyActivity.map((month) => ( +
+
+ {monthNames[month.month - 1]} +
+
+
0 ? (month.count / maxMonthlyCount) * 100 : 0}%`, + }} + /> +
+
+ {month.count} +
+
+ ))} +
+ + + {/* Summary Stats */} + +
+
+ +

+ {stats.totalFavorites} +

+

Favorites

+
+
+ +

{stats.totalTags}

+

Tags Created

+
+
+ +

+ {stats.totalHighlights} +

+

Highlights

+
+
+
+
+ +

+ {stats.bookmarksByType.link} +

+

Links

+
+
+ +

+ {stats.bookmarksByType.text} +

+

Notes

+
+
+ +

+ {stats.bookmarksByType.asset} +

+

Assets

+
+
+
+
+ + {/* Footer */} +
+ Made with Karakeep +
+
+
+ ); + }, +); + +WrappedContent.displayName = "WrappedContent"; -- cgit v1.2.3-70-g09d2