web: wrapped 2025

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

web: refined wrapped ui

web: make wrapped work automatically for future years

web: fix emoji for colors

web: format word count
This commit is contained in:
01zulfi
2025-12-04 10:42:32 +05:00
committed by Ammar Ahmed
parent 122df1bb35
commit 7ccfba67e5
13 changed files with 2738 additions and 35 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,7 @@
"file-saver": "^2.0.5",
"hash-wasm": "4.12.0",
"hotkeys-js": "^3.8.3",
"htmlparser2": "^10.0.0",
"katex": "0.16.11",
"mac-scrollbar": "0.13.6",
"mutative": "^1.1.0",

View File

@@ -48,6 +48,7 @@ import { getFontSizes } from "@notesnook/theme/theme/font/fontsize.js";
import { useWindowControls } from "./hooks/use-window-controls";
import { STATUS_BAR_HEIGHT } from "./common/constants";
import { NavigationEvents } from "./navigation";
import { db } from "./common/db";
new WebExtensionRelay();
@@ -143,6 +144,10 @@ function DesktopAppContents() {
const isTablet = useTablet();
const navPane = useRef<SplitPaneImperativeHandle>(null);
useEffect(() => {
(async () => {})();
}, []);
useEffect(() => {
if (isTablet) navPane.current?.collapse(0);
else if (navPane.current?.isCollapsed(0)) navPane.current?.expand(0);

View File

@@ -25,6 +25,7 @@ import {
isFeatureSupported
} from "./utils/feature-check";
import { initializeLogger } from "./utils/logger";
import { shouldShowWrapped } from "./utils/should-show-wrapped";
type Route<TProps = null> = {
component: () => Promise<{
@@ -55,6 +56,9 @@ const routes = {
"/plans": {
component: () => import("./views/plans")
},
"/wrapped": {
component: () => import("./views/wrapped")
},
"/checkout": {
component: () => import("./views/checkout")
},
@@ -122,6 +126,12 @@ function getRoute(): RouteWithPath<AuthProps> | RouteWithPath {
routes[path] ? { route: routes[path], path } : null
) as RouteWithPath<AuthProps> | null;
if (route?.path === "/wrapped" && !shouldShowWrapped())
return {
route: routes.default,
path: "default"
};
return signup || sessionExpired || route || fallback;
}

View File

@@ -0,0 +1,143 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box, Flex, Text } from "@theme-ui/components";
import { SxProp } from "@theme-ui/core";
type MonthlyActivityHeatmapProps = {
monthlyStats: Record<string, number>;
year?: number;
} & SxProp;
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
];
const MONTHS_SHORT = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
];
export function MonthlyActivityHeatmap({
monthlyStats
}: MonthlyActivityHeatmapProps) {
// Find max count for bar height calculation
const maxCount = Math.max(...Object.values(monthlyStats), 1);
// Create month data with counts
const monthData = MONTHS.map((month) => ({
month,
count: monthlyStats[month] || 0
}));
return (
<>
{/* Bar chart */}
<Flex
sx={{
alignItems: "flex-end",
justifyContent: "space-between",
gap: 2,
height: "100%",
width: "100%",
position: "relative",
pb: 2
}}
>
{monthData.map((data, index) => {
const barHeight = maxCount > 0 ? (data.count / maxCount) * 100 : 0;
return (
<Flex
key={data.month}
sx={{
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-end",
flex: 1,
gap: 2,
cursor: "pointer",
"&:hover .tooltip": { opacity: 1 },
"&:hover .bar": { bg: "accent" },
height: "100%"
}}
>
<Flex
className="tooltip"
sx={{ opacity: 0, bg: "background", zIndex: 1000 }}
>
<Text variant="body" sx={{ fontWeight: "bold" }}>
{data.count}
</Text>
</Flex>
<Box
className="bar"
sx={{
width: "100%",
height: `${barHeight}%`,
minHeight: data.count > 0 ? "10px" : "5px",
bg: maxCount === data.count ? "accent" : "paragraph",
borderRadius: "4px 4px 0 0",
transition: "all 0.3s ease",
position: "relative",
"&:hover": {
transform: "scaleY(1.05)",
transformOrigin: "bottom"
}
}}
></Box>
<Text
sx={{
fontSize: "11px",
color: "paragraph-secondary",
fontWeight: "normal",
transition: "all 0.3s ease"
}}
>
{MONTHS_SHORT[index]}
</Text>
</Flex>
);
})}
</Flex>
</>
);
}

View File

@@ -97,7 +97,6 @@ import { strings } from "@notesnook/intl";
import Tags from "../../views/tags";
import { Notebooks } from "../../views/notebooks";
import { UserProfile } from "../../dialogs/settings/components/user-profile";
import { SUBSCRIPTION_STATUS } from "../../common/constants";
import {
checkFeature,
createSetDefaultHomepageMenuItem,
@@ -105,7 +104,6 @@ import {
withFeatureCheck
} from "../../common";
import { TabItem } from "./tab-item";
import Notice from "../notice";
import { Freeze } from "react-freeze";
import { CREATE_BUTTON_MAP } from "../../common";
import { useStore as useNotebookStore } from "../../stores/notebook-store";
@@ -118,6 +116,7 @@ import {
useIsFeatureAvailable
} from "@notesnook/common";
import { isUserSubscribed } from "../../hooks/use-is-user-premium";
import { shouldShowWrapped } from "../../utils/should-show-wrapped";
type Route = {
id: "notes" | "favorites" | "reminders" | "monographs" | "trash" | "archive";
@@ -449,7 +448,17 @@ function NavigationMenu({ onExpand }: { onExpand?: () => void }) {
</FlexScrollContainer>
</Freeze>
</Flex>
{currentTab.id === "home" && !isCollapsed ? <Notice /> : null}
{currentTab.id === "home" && !isCollapsed && shouldShowWrapped() ? (
<Button
variant="accent"
sx={{ m: 2 }}
onClick={() => {
hardNavigate("/wrapped");
}}
>
🎉 Wrapped {new Date().getFullYear()}
</Button>
) : null}
</ScopedThemeProvider>
);
}

View File

@@ -0,0 +1,23 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export const shouldShowWrapped = () => {
const now = new Date();
return now.getMonth() === 11;
};

View File

@@ -0,0 +1,776 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Button, Flex, Text, Box, FlexProps } from "@theme-ui/components";
import { db } from "../common/db";
import { useState, useEffect, useRef } from "react";
import { NoteStats, WrappedStats } from "@notesnook/core";
import { formatBytes } from "@notesnook/common";
import { ArrowDown, ArrowLeft, Loading } from "../components/icons";
import { hardNavigate } from "../navigation";
import { MonthlyActivityHeatmap } from "../components/monthly-activity-heatmap";
function formatNumber(num: number) {
return num.toLocaleString();
}
function formatCount(num: number) {
return num > 1000 ? `${(num / 1000).toFixed(0)}k` : num.toString();
}
interface SlideProps {
children: React.ReactNode;
pattern?: "dots" | "grid" | "diagonal" | "none";
}
function Slide({
children,
pattern = "none",
sx,
...flexProps
}: SlideProps & FlexProps) {
const slideRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{ threshold: 0.5 }
);
if (slideRef.current) {
observer.observe(slideRef.current);
}
return () => {
if (slideRef.current) {
observer.unobserve(slideRef.current);
}
};
}, []);
const getBackgroundPattern = () => {
switch (pattern) {
case "dots":
return "radial-gradient(circle, var(--border) 1px, transparent 1px)";
case "grid":
return "linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px)";
case "diagonal":
return "repeating-linear-gradient(-45deg, transparent, transparent 20px, var(--border) 20px, var(--border) 21px)";
default:
return "none";
}
};
const getBackgroundSize = () => {
switch (pattern) {
case "dots":
return "20px 20px";
case "grid":
return "30px 30px";
case "diagonal":
default:
return "auto";
}
};
return (
<Flex
ref={slideRef}
sx={{
minHeight: "100vh",
minWidth: "100%",
scrollSnapAlign: "start",
scrollSnapStop: "always",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
p: 4,
position: "relative",
backgroundImage: getBackgroundPattern(),
backgroundSize: getBackgroundSize(),
backgroundPosition: "center"
}}
>
<Flex
{...flexProps}
sx={{
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
opacity: isVisible ? 1 : 0,
transform: isVisible ? "translateY(0)" : "translateY(50px)",
transition: "opacity 0.8s ease-out, transform 0.8s ease-out",
...sx
}}
>
{children}
</Flex>
</Flex>
);
}
function WelcomeSlide({ loading }: { loading: boolean }) {
return (
<Slide pattern="dots">
<Text
sx={{
fontSize: "6rem",
fontWeight: "bold",
mb: 3,
textAlign: "center",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
animation: "slideUp 1s ease-out"
}}
>
🎉
</Text>
<Text
variant="heading"
sx={{
fontSize: ["2rem", "3rem", "4rem"],
fontWeight: "bold",
mb: 2,
textAlign: "center",
animation: "slideUp 1s ease-out 0.2s both"
}}
>
Your {new Date().getFullYear()} Wrapped
</Text>
{loading ? (
<Loading
sx={{
animation: "fadeIn 1s ease-out 0.4s both"
}}
size={30}
/>
) : (
<>
<Text
sx={{
fontSize: ["1rem", "1.2rem"],
textAlign: "center",
animation: "fadeIn 1s ease-out 0.4s both"
}}
>
Let&apos;s look back at your year in Notesnook
</Text>
<Text
variant="body"
sx={{
mt: 4,
fontSize: "title",
color: "paragraph-secondary",
textAlign: "center",
animation: "fadeIn 1s ease-out 0.4s both"
}}
>
Scroll down to explore
</Text>
<ArrowDown sx={{ mt: 2 }} color="paragraph-secondary" />
</>
)}
</Slide>
);
}
function TotalNotesSlide({ count }: { count: number }) {
return (
<Slide pattern="dots">
<Flex>
<Flex sx={{ flexDirection: "column" }}>
<Text
variant="body"
sx={{
fontSize: ["1.2rem", "1.5rem"],
textAlign: "center",
animation: "fadeIn 0.8s ease-out"
}}
>
You created
</Text>
<Text
variant="heading"
sx={{
fontSize: ["5rem", "7rem", "8rem"],
textAlign: "center",
color: "accent",
animation: "scaleIn 0.8s ease-out 0.2s both"
}}
>
{formatNumber(count)}
</Text>
<Text
variant="body"
sx={{
fontSize: ["1.2rem", "1.5rem"],
textAlign: "center",
animation: "fadeIn 0.8s ease-out 0.4s both"
}}
>
notes this year
</Text>
</Flex>
<Flex
sx={{
flexDirection: "column",
ml: [0, 5],
mt: [4, 0],
borderLeft: ["none", "1px solid var(--border)"],
pl: [0, 5],
borderTop: ["1px solid var(--border)", "none"]
}}
>
<Text
variant="body"
color="paragraph-secondary"
sx={{
fontSize: ["1.2rem", "1.2rem"],
animation: "fadeIn 0.8s ease-out",
lineHeight: 1.8
}}
>
That&apos;s <strong>{formatNumber(count)}</strong>
<br />
ideas
<br />
thoughts
<br />
memories.
<br />
100% encrypted.
<br />
100% yours.
</Text>
</Flex>
</Flex>
</Slide>
);
}
const TOTAL_WORDS_TAGLINES = {
1000: "That's longer than the average blog post on Medium!",
10000: "That's real commitment to your mindspace.",
25000: "That's almost the length of a short novel!",
50000:
"That's the length of The Great Gatsby if you were F. Scott Fitzgerald.",
100000: "You wrote more than many published authors this year.",
250000: "Woah! Your notes could fill a small library.",
500000: "Your vault is becoming a chronicle.",
1000000: "That's longer than the entire Harry Potter series!"
};
function TotalWordsSlide({ count }: { count: number }) {
const tagline = Object.entries(TOTAL_WORDS_TAGLINES)
.sort((a, b) => Number(b[0]) - Number(a[0]))
.find(([threshold]) => count >= Number(threshold))?.[1];
return (
<Slide pattern="dots">
<Text
variant="body"
sx={{
fontSize: ["1.2rem", "1.5rem"],
textAlign: "center",
animation: "fadeIn 0.8s ease-out"
}}
>
You wrote a total of
</Text>
<Text
variant="heading"
sx={{
fontSize: ["5rem", "7rem"],
textAlign: "center",
color: "accent",
animation: "scaleIn 0.8s ease-out 0.2s both"
}}
>
{formatNumber(count)}
</Text>
<Text
variant="body"
sx={{
fontSize: ["1.2rem", "1.5rem"],
textAlign: "center",
animation: "fadeIn 0.8s ease-out 0.4s both"
}}
>
words this year
</Text>
{tagline ? (
<Text
variant="body"
color="paragraph-secondary"
sx={{
fontSize: ["1.2rem", "1.2rem"],
animation: "fadeIn 0.8s ease-out",
borderTop: "1px solid var(--border)",
mt: 3,
pt: 3
}}
>
{tagline}
</Text>
) : null}
</Slide>
);
}
type ActivityStatsSlideProps = {
mostNotesCreatedInMonth: NoteStats["mostNotesCreatedInMonth"];
mostNotesCreatedInDay: NoteStats["mostNotesCreatedInDay"];
};
function ActivityStatsSlide({
mostNotesCreatedInMonth,
mostNotesCreatedInDay
}: ActivityStatsSlideProps) {
if (!mostNotesCreatedInMonth && !mostNotesCreatedInDay) return null;
return (
<Slide pattern="diagonal" sx={{ alignItems: "start" }}>
{mostNotesCreatedInMonth && (
<>
<Text
variant="body"
sx={{
fontSize: ["1.2rem", "1.5rem"]
}}
>
Your most productive month was
</Text>
<Flex
sx={{
alignItems: "center",
justifyContent: "space-between",
width: "100%"
}}
>
<Text
variant="heading"
sx={{
fontSize: ["5rem", "7rem", "3rem"]
}}
>
{mostNotesCreatedInMonth.month}
</Text>
<Text
variant="body"
sx={{ fontSize: "1rem" }}
color="paragraph-secondary"
>
{formatNumber(mostNotesCreatedInMonth.count)} notes
</Text>
</Flex>
</>
)}
{mostNotesCreatedInDay && (
<>
<Text
variant="body"
sx={{
fontSize: ["1.2rem", "1.5rem"],
mt: 5
}}
>
Your favorite day to write was
</Text>
<Flex
sx={{
alignItems: "center",
justifyContent: "space-between",
width: "100%"
}}
>
<Text
variant="heading"
sx={{
fontSize: ["5rem", "7rem", "3rem"]
}}
>
{mostNotesCreatedInDay.day}
</Text>
<Text
variant="body"
sx={{ fontSize: "1rem" }}
color="paragraph-secondary"
>
{formatNumber(mostNotesCreatedInDay.count)} notes
</Text>
</Flex>
</>
)}
</Slide>
);
}
function SummarySlide({ stats }: { stats: WrappedStats }) {
return (
<Slide
pattern="dots"
sx={{
bg: "background-secondary",
border: "1px solid var(--accent)",
borderRadius: "dialog",
boxShadow: "-15px 15px 0px 0px var(--accent)",
p: 5
}}
>
<svg
style={{
height: 20,
width: 20,
position: "absolute",
bottom: "20px",
right: "20px"
}}
>
<use href="#themed-logo" />
</svg>
<Text
variant="heading"
sx={{
fontSize: "1.2rem",
fontWeight: 800,
textAlign: "center",
transform: "skew(-10deg) scaleX(1.5)",
letterSpacing: "-1px",
mb: "25px",
textDecorationLine: "underline",
textDecorationColor: "border"
}}
>
NOTESNOOK WRAPPED {new Date().getFullYear()}
</Text>
<Flex
sx={{
flexDirection: ["column", "row"],
gap: "30px",
justifyContent: "center",
alignItems: "center"
}}
>
<Flex
sx={{
flexDirection: "column",
flex: 1
}}
>
<Box
sx={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gridTemplateRows: "1fr 1fr",
gap: 4,
height: "100%"
}}
>
{[
{
icon: "📝",
count: stats.totalNotes,
label: "Notes"
},
{
icon: "🎨",
count: stats.totalColors,
label: "Colors"
},
{
icon: "📚",
count: stats.totalNotebooks,
label: "Notebooks"
},
{
icon: "🏷️",
count: stats.totalTags,
label: "Tags"
},
{
icon: "📂",
count: stats.totalAttachments,
label: "Files"
},
{
icon: "☁️",
count: stats.totalMonographs,
label: "Monographs"
}
].map(({ icon, count, label }) => (
<Flex
key={label}
sx={{
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1
}}
>
<Text sx={{ fontSize: "1.5rem" }}>{icon}</Text>
<Text
sx={{
fontSize: "2rem",
fontWeight: "bold"
}}
>
{formatCount(count)}
</Text>
<Text sx={{ fontSize: "0.85rem", color: "fontTertiary" }}>
{label}
</Text>
</Flex>
))}
</Box>
<Flex
sx={{
flexDirection: "column",
gap: 2,
borderTop: "1px solid var(--border)",
mt: 6,
pt: 3,
"& strong": {
color: "accent"
}
}}
>
<Text sx={{ fontSize: "1rem", fontWeight: "bold" }}>
Fun facts of the year
</Text>
{stats.mostNotesCreatedInMonth && (
<Text sx={{ fontSize: "0.9rem", color: "fontTertiary" }}>
📅 Your most productive month was{" "}
<Text as="strong">{stats.mostNotesCreatedInMonth.month}</Text>
</Text>
)}
{stats.mostNotesCreatedInDay && (
<Text sx={{ fontSize: "0.9rem", color: "fontTertiary" }}>
🗓 Your favorite day to write was{" "}
<Text as="strong">{stats.mostNotesCreatedInDay.day}</Text>
</Text>
)}
{stats.largestNote && (
<Text
sx={{
fontSize: "0.9rem",
color: "fontTertiary",
maxWidth: 340
}}
>
📝 Your longest note was{" "}
<Text as="strong">
{formatNumber(stats.largestNote.length)}
{" words"}
</Text>
</Text>
)}
{stats.largestAttachment && (
<Text
sx={{
fontSize: "0.9rem",
color: "fontTertiary",
maxWidth: 340
}}
>
🔗 Your largest attachment was{" "}
<strong>{formatBytes(stats.largestAttachment.size)}</strong>
</Text>
)}
</Flex>
</Flex>
<Flex
sx={{
flexDirection: "column",
justifyContent: "stretch",
height: "100%",
flex: 1
}}
>
<Flex
sx={{
flexDirection: "column",
justifyContent: "center",
alignItems: "center"
}}
>
<Text sx={{ fontSize: "2.5rem" }}></Text>
<Text
sx={{
fontSize: "3rem",
fontWeight: "bold",
color: "accent"
}}
>
{formatNumber(stats.totalWords)}
</Text>
<Text sx={{ fontSize: "1rem", color: "fontTertiary" }}>
Words Written
</Text>
</Flex>
<Flex
sx={{
flexDirection: "column",
borderTop: "1px solid var(--border)",
mt: 6,
pt: 2,
flex: 1
}}
>
<MonthlyActivityHeatmap monthlyStats={stats.monthlyStats} />
<Text
sx={{
fontSize: "1rem",
color: "fontTertiary",
textAlign: "center"
}}
>
Notes per month
</Text>
</Flex>
</Flex>
</Flex>
<Text
variant="body"
color="paragraph-secondary"
sx={{ mt: 2, borderTop: "1px solid var(--border)", pt: 2 }}
>
Generated 100% locally on your device.
</Text>
</Slide>
);
}
export default function Wrapped() {
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<WrappedStats>();
useEffect(() => {
async function loadWrapped() {
setLoading(true);
try {
console.time("wrapped - getting from core");
const wrapped = await db.wrapped.get();
console.timeEnd("wrapped - getting from core");
setStats(wrapped);
} finally {
setLoading(false);
}
}
loadWrapped();
}, []);
return (
<>
<style>
{`
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
`}
</style>
<Flex
sx={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
flexDirection: "column",
overflowY: "scroll",
overflowX: "hidden",
scrollSnapType: "y mandatory",
scrollBehavior: "smooth",
"&::-webkit-scrollbar": {
display: "none"
},
msOverflowStyle: "none",
scrollbarWidth: "none"
}}
>
<WelcomeSlide loading={loading} />
{stats ? (
<>
<TotalNotesSlide count={stats.totalNotes} />
{stats.totalWords > 0 && (
<TotalWordsSlide count={stats.totalWords} />
)}
{(stats.mostNotesCreatedInMonth || stats.mostNotesCreatedInDay) && (
<ActivityStatsSlide
mostNotesCreatedInMonth={stats.mostNotesCreatedInMonth}
mostNotesCreatedInDay={stats.mostNotesCreatedInDay}
/>
)}
<SummarySlide stats={stats} />
</>
) : null}
</Flex>
<Button
onClick={() => hardNavigate("/")}
variant="secondary"
sx={{
position: "fixed",
top: 3,
left: 3,
zIndex: 1000
}}
>
<Flex sx={{ alignItems: "center", gap: 1, justifyContent: "center" }}>
<ArrowLeft size={16} />
<Text variant="body">Go back to app</Text>
</Flex>
</Button>
</>
);
}

View File

@@ -18,6 +18,7 @@
"@streetwriters/kysely": "^0.27.4",
"@streetwriters/showdown": "^3.0.9-alpha",
"@types/mime-db": "^1.43.5",
"alfaaz": "^1.1.0",
"async-mutex": "0.5.0",
"dayjs": "1.11.13",
"dom-serializer": "^2.0.0",
@@ -1391,6 +1392,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/alfaaz": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/alfaaz/-/alfaaz-1.1.0.tgz",
"integrity": "sha512-J/P07R41APslK7NmD5303bwStN8jpRA4DdvtLeAr1Jhfj6XWGrASUWI0G6jbWjJAZyw3Lu1Pb4J8rsM/cb+xDQ==",
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",

View File

@@ -73,6 +73,7 @@
"@streetwriters/kysely": "^0.27.4",
"@streetwriters/showdown": "^3.0.9-alpha",
"@types/mime-db": "^1.43.5",
"alfaaz": "^1.1.0",
"async-mutex": "0.5.0",
"dayjs": "1.11.13",
"dom-serializer": "^2.0.0",

View File

@@ -83,6 +83,7 @@ import { ConfigStorage } from "../database/config.js";
import { LazyPromise } from "../utils/lazy-promise.js";
import { InboxApiKeys } from "./inbox-api-keys.js";
import { Circle } from "./circle.js";
import { Wrapped } from "./wrapped.js";
type EventSourceConstructor = new (
uri: string,
@@ -224,6 +225,8 @@ class Database {
inboxApiKeys = new InboxApiKeys(this, this.tokenManager);
wrapped = new Wrapped(this);
/**
* @deprecated only kept here for migration purposes
*/

View File

@@ -0,0 +1,342 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Parser } from "htmlparser2";
import Database from "./index.js";
import { countWords } from "alfaaz";
import { DatabaseSchema, isFalse } from "../database/index.js";
import { SelectQueryBuilder } from "@streetwriters/kysely";
const dayNames = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday"
];
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
];
export type NoteStats = {
totalNotes: number;
totalWords: number;
totalMonographs: number;
mostNotesCreatedInMonth: { month: string; count: number } | null;
mostNotesCreatedInDay: { day: string; count: number } | null;
monthlyStats: Record<string, number>;
dayOfWeekStats: Record<string, number>;
largestNote: { title: string; length: number } | null;
};
export type OrganizationStats = {
totalNotebooks: number;
totalTags: number;
mostUsedTags: { id: string; title: string; noteCount: number }[];
mostActiveNotebooks: { id: string; title: string; noteCount: number }[];
totalColors: number;
};
export type AttachmentStats = {
totalAttachments: number;
totalStorageUsed: number;
largestAttachment: { id: string; filename: string; size: number } | null;
mostCommonFileType: string | null;
};
export type WrappedStats = NoteStats & OrganizationStats & AttachmentStats;
export class Wrapped {
constructor(private readonly db: Database) {}
async get(): Promise<WrappedStats> {
const { startDate, endDate } = this.getYearRange();
const [noteStats, organizationStats, attachmentStats] = await Promise.all([
this.getNoteStats(startDate, endDate),
this.getOrganizationStats(startDate, endDate),
this.getAttachmentStats(startDate, endDate)
]);
return {
...noteStats,
...organizationStats,
...attachmentStats
};
}
private getYearRange(): { startDate: number; endDate: number } {
const year = new Date().getFullYear();
const startDate = new Date(year, 0, 1, 0, 0, 0, 0).getTime();
const endDate = new Date(year + 1, 0, 1, 0, 0, 0, 0).getTime();
return { startDate, endDate };
}
private async getNoteStats(
startDate: number,
endDate: number
): Promise<NoteStats> {
const notesSelector = this.db
.sql()
.selectFrom("notes")
.where((eb) => eb("dateCreated", ">=", startDate))
.where((eb) => eb("dateCreated", "<", endDate))
.where(isFalse("deleted"))
.where(isFalse("dateDeleted"));
const notes = await notesSelector.select(["notes.dateCreated"]).execute();
const monthlyStats: Map<string, number> = new Map();
const dayOfWeekStats: Map<string, number> = new Map();
let totalNotes = 0;
for (const note of notes) {
if (!note.dateCreated) continue;
totalNotes++;
const month = new Date(note.dateCreated).getMonth();
const monthName = monthNames[month];
monthlyStats.set(monthName, (monthlyStats.get(monthName) || 0) + 1);
const dayOfWeek = new Date(note.dateCreated).getDay();
const dayName = dayNames[dayOfWeek];
dayOfWeekStats.set(dayName, (dayOfWeekStats.get(dayName) || 0) + 1);
}
let mostNotesCreatedInMonth: NoteStats["mostNotesCreatedInMonth"] = null;
for (const [month, count] of monthlyStats.entries()) {
if (!mostNotesCreatedInMonth || count > mostNotesCreatedInMonth.count) {
mostNotesCreatedInMonth = { month, count };
}
}
let mostNotesCreatedInDay: NoteStats["mostNotesCreatedInDay"] = null;
let maxDayCount = 0;
for (const [day, count] of dayOfWeekStats.entries()) {
if (count > maxDayCount) {
maxDayCount = count;
mostNotesCreatedInDay = { day, count };
}
}
const totalMonographs = await this.db.monographs.all
.where((eb) =>
eb.and([
eb("dateCreated", ">=", startDate),
eb("dateCreated", "<", endDate)
])
)
.count();
const { largestNote, totalWords } = await this.countTotalWords(
notesSelector
);
return {
totalNotes,
totalWords,
totalMonographs,
largestNote: largestNote
? {
title: (await this.db.notes.note(largestNote.id))?.title || "",
length: largestNote.wordCount
}
: null,
monthlyStats: Object.fromEntries(monthlyStats),
dayOfWeekStats: Object.fromEntries(dayOfWeekStats),
mostNotesCreatedInMonth,
mostNotesCreatedInDay
};
}
private async countItemNotes<T extends { id: string; title: string }>(
items: T[],
itemType: "tag" | "notebook"
): Promise<Array<T & { noteCount: number }>> {
const allRelations = await this.db.relations
.from({ ids: items.map((item) => item.id), type: itemType }, "note")
.get();
const noteCounts: Map<string, number> = new Map();
for (const relation of allRelations) {
const itemId = relation.fromId;
noteCounts.set(itemId, (noteCounts.get(itemId) || 0) + 1);
}
return items
.map((item) => ({
...item,
noteCount: noteCounts.get(item.id) || 0
}))
.filter((item) => item.noteCount > 0)
.sort((a, b) => b.noteCount - a.noteCount);
}
private async getOrganizationStats(
startDate: number,
endDate: number
): Promise<OrganizationStats> {
const notebookSelector = this.db.notebooks.all
.where((eb) => eb("dateCreated", ">=", startDate))
.where((eb) => eb("dateCreated", "<", endDate));
const tagSelector = this.db.tags.all
.where((eb) => eb("dateCreated", ">=", startDate))
.where((eb) => eb("dateCreated", "<", endDate));
const [totalNotebooks, totalTags, tags, notebooks, totalColors] =
await Promise.all([
notebookSelector.count(),
tagSelector.count(),
tagSelector.fields(["tags.id", "tags.title"]).items(),
notebookSelector.fields(["notebooks.id", "notebooks.title"]).items(),
this.db.colors.all
.where((eb) => eb("dateCreated", ">=", startDate))
.where((eb) => eb("dateCreated", "<", endDate))
.count()
]);
const tagNotes = await this.countItemNotes(tags, "tag");
const mostUsedTags = tagNotes.slice(0, 3);
const notebookNotes = await this.countItemNotes(notebooks, "notebook");
const mostActiveNotebooks = notebookNotes.slice(0, 3);
return {
totalNotebooks,
totalTags,
mostUsedTags:
mostUsedTags.length > 0
? mostUsedTags
: tags.slice(0, 3).map((tag) => ({ ...tag, noteCount: 0 })),
mostActiveNotebooks:
mostActiveNotebooks.length > 0
? mostActiveNotebooks
: notebooks.slice(0, 3).map((n) => ({ ...n, noteCount: 0 })),
totalColors
};
}
private async getAttachmentStats(
startDate: number,
endDate: number
): Promise<AttachmentStats> {
const attachmentsSelector = this.db.attachments.all
.where((eb) => eb("dateCreated", ">=", startDate))
.where((eb) => eb("dateCreated", "<", endDate));
const totalAttachments = await attachmentsSelector.count();
if (totalAttachments === 0) {
return {
totalAttachments: 0,
totalStorageUsed: 0,
largestAttachment: null,
mostCommonFileType: null
};
}
const totalStorageUsed =
(await this.db.attachments.totalSize(attachmentsSelector)) || 0;
const attachments = await attachmentsSelector.items();
let largestAttachment: AttachmentStats["largestAttachment"] = null;
const mimeTypeCounts: Map<string, number> = new Map();
for (const attachment of attachments) {
if (!largestAttachment || attachment.size > largestAttachment.size) {
largestAttachment = {
id: attachment.id,
filename: attachment.filename,
size: attachment.size
};
}
const mimeType = attachment.mimeType.split("/")[0] || attachment.mimeType;
mimeTypeCounts.set(mimeType, (mimeTypeCounts.get(mimeType) || 0) + 1);
}
let mostCommonFileType: string | null = null;
let maxCount = 0;
for (const [mimeType, count] of mimeTypeCounts.entries()) {
if (count > maxCount) {
maxCount = count;
mostCommonFileType = mimeType;
}
}
return {
totalAttachments,
totalStorageUsed,
largestAttachment,
mostCommonFileType
};
}
private async countTotalWords(
selector: SelectQueryBuilder<DatabaseSchema, "notes", unknown>
) {
let words = 0;
let largestNote = { id: "", wordCount: 0 };
const contents = await this.db
.sql()
.selectFrom("content")
.where("noteId", "in", selector.select("id"))
.where(isFalse("locked"))
.where(isFalse("deleted"))
.select(["content.data", "content.noteId"])
.execute();
for (const content of contents) {
if (typeof content?.data !== "string") continue;
const counted = countWords(toTextContent(content.data));
words += counted;
if (content.noteId && counted > largestNote.wordCount) {
largestNote = { id: content.noteId, wordCount: counted };
}
}
return { totalWords: words, largestNote };
}
}
function toTextContent(html: string) {
let text = "";
const parser = new Parser({
ontext: (data) => {
text += data;
},
onclosetag() {
text += " ";
}
});
parser.write(html);
parser.end();
return text;
}

View File

@@ -45,3 +45,4 @@ export type { SyncOptions } from "./api/sync/index.js";
export { sanitizeTag } from "./collections/tags.js";
export { default as DataURL } from "./utils/dataurl.js";
export { type ResolveInternalLink } from "./content-types/tiptap.js";
export type * from "./api/wrapped.js";