diff --git a/.gitignore b/.gitignore
index cd28cf17..0072984b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
/.env
/.env.local
+/.worktrees
# Created by https://www.toptal.com/developers/gitignore/api/osx,windows,linux,nextjs,react,node
# Edit at https://www.toptal.com/developers/gitignore?templates=osx,windows,linux,nextjs,react,node
diff --git a/package.json b/package.json
index ddb68df6..a532f584 100644
--- a/package.json
+++ b/package.json
@@ -62,6 +62,7 @@
"react-dropzone": "^14.3.8",
"react-globe.gl": "^2.37.0",
"react-markdown": "^9.0.1",
+ "recharts": "^3.8.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0",
diff --git a/src/app/(app)/[account_id]/IndividualProfilePage.tsx b/src/app/(app)/[account_id]/IndividualProfilePage.tsx
index 7aa92d76..9f18e2ff 100644
--- a/src/app/(app)/[account_id]/IndividualProfilePage.tsx
+++ b/src/app/(app)/[account_id]/IndividualProfilePage.tsx
@@ -8,31 +8,33 @@ import { type IndividualAccount, Actions } from "@/types";
import { getPageSession } from "@/lib/api/utils";
import { isAuthorized } from "@/lib/api/authz";
import { IndividualProfile } from "@/components/features/profiles/IndividualProfile";
+import { getAccountAnalytics, type Period } from "@/lib/clients/analytics";
interface IndividualProfilePageProps {
account: IndividualAccount;
showWelcome: boolean;
+ period?: Period;
}
export async function IndividualProfilePage({
account,
showWelcome,
+ period = 7,
}: IndividualProfilePageProps) {
const session = await getPageSession();
- let { products } = await productsTable.listByAccount(
- account.account_id,
- 1000
- );
+ let [{ products }, membershipsRaw, analyticsData] = await Promise.all([
+ productsTable.listByAccount(account.account_id, 1000),
+ membershipsTable.listByUser(account.account_id),
+ getAccountAnalytics(account.account_id, period),
+ ]);
// Filter products based on authentication status
products = products.filter((product) =>
isAuthorized(session, product, Actions.GetRepository)
);
- const memberships = (
- await membershipsTable.listByUser(account.account_id)
- ).filter((membership) =>
+ const memberships = membershipsRaw.filter((membership) =>
isAuthorized(account, membership, Actions.GetMembership)
);
const organizations = (
@@ -51,6 +53,8 @@ export async function IndividualProfilePage({
organizations={organizations}
showWelcome={showWelcome}
canEdit={isAuthorized(session, account, Actions.PutAccountProfile)}
+ analyticsData={analyticsData}
+ analyticsPeriod={period}
/>
);
}
diff --git a/src/app/(app)/[account_id]/OrganizationProfilePage.tsx b/src/app/(app)/[account_id]/OrganizationProfilePage.tsx
index 28c3c090..026eb1b4 100644
--- a/src/app/(app)/[account_id]/OrganizationProfilePage.tsx
+++ b/src/app/(app)/[account_id]/OrganizationProfilePage.tsx
@@ -17,21 +17,25 @@ import {
import { getPageSession } from "@/lib/api/utils";
import { isAuthorized } from "@/lib/api/authz";
import { getPendingInvitation } from "@/lib/actions/memberships";
+import { getAccountAnalytics, type Period } from "@/lib/clients/analytics";
interface OrganizationProfilePageProps {
account: OrganizationalAccount;
+ period?: Period;
}
export async function OrganizationProfilePage({
account,
+ period = 7,
}: OrganizationProfilePageProps) {
// Get session to check authentication status
const session = await getPageSession();
const isAuthenticated = session?.account && !session.account.disabled;
- let [memberships, { products }] = await Promise.all([
+ let [memberships, { products }, analyticsData] = await Promise.all([
membershipsTable.listByAccount(account.account_id),
productsTable.listByAccount(account.account_id),
+ getAccountAnalytics(account.account_id, period),
]);
memberships = memberships
@@ -106,6 +110,8 @@ export async function OrganizationProfilePage({
admins={admins}
members={members}
canEdit={isAuthorized(session, account, Actions.PutAccountProfile)}
+ analyticsData={analyticsData}
+ analyticsPeriod={period}
/>
diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/loading.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/loading.tsx
new file mode 100644
index 00000000..17f62776
--- /dev/null
+++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/loading.tsx
@@ -0,0 +1,3 @@
+export default function AnalyticsLoading() {
+ return null;
+}
diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/page.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/page.tsx
new file mode 100644
index 00000000..21587c8c
--- /dev/null
+++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@analytics/page.tsx
@@ -0,0 +1,41 @@
+import { ProductAnalytics } from "@/components/features/analytics/ProductAnalytics";
+import { getProductAnalytics, getPopularFiles, type Period } from "@/lib/clients/analytics";
+
+function parsePeriod(value: string | undefined): Period {
+ const num = Number(value);
+ if (num === 7 || num === 30 || num === 90) return num;
+ return 7;
+}
+
+interface PageProps {
+ params: Promise<{ account_id: string; product_id: string; path?: string[] }>;
+ searchParams: Promise<{ period?: string }>;
+}
+
+export default async function ProductAnalyticsSlot({
+ params,
+ searchParams,
+}: PageProps) {
+ const { account_id, product_id, path } = await params;
+ const { period: periodParam } = await searchParams;
+ const period = parsePeriod(periodParam);
+
+ const filePath = path?.map((p) => decodeURIComponent(p)).join("/") || undefined;
+
+ const [data, popularFiles] = await Promise.all([
+ getProductAnalytics(account_id, product_id, period, filePath),
+ filePath
+ ? Promise.resolve([])
+ : getPopularFiles(account_id, product_id, period),
+ ]);
+
+ return (
+
+ );
+}
diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx
index 453d9242..195f6ec4 100644
--- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx
+++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx
@@ -26,6 +26,7 @@ import { getPendingInvitation } from "@/lib/actions/memberships";
interface ProductLayoutProps {
children: React.ReactNode;
readme: React.ReactNode;
+ analytics: React.ReactNode;
params: Promise<{ account_id: string; product_id: string; path?: string[] }>;
}
@@ -33,6 +34,7 @@ export default async function ProductLayout({
params,
children,
readme,
+ analytics,
}: ProductLayoutProps) {
// Then check if product exists
const { account_id, product_id, path } = await params;
@@ -60,6 +62,7 @@ export default async function ProductLayout({
+ {analytics}
diff --git a/src/app/(app)/[account_id]/page.tsx b/src/app/(app)/[account_id]/page.tsx
index 17c3af41..9cfbb933 100644
--- a/src/app/(app)/[account_id]/page.tsx
+++ b/src/app/(app)/[account_id]/page.tsx
@@ -19,10 +19,17 @@ import {
generateNotFoundMetadata,
generateAccountMetadata,
} from "@/components/features/metadata";
+import type { Period } from "@/lib/clients/analytics";
+
+function parsePeriod(value: string | undefined): Period {
+ const num = Number(value);
+ if (num === 7 || num === 30 || num === 90) return num;
+ return 7;
+}
type PageProps = {
params: Promise<{ account_id: string }>;
- searchParams: Promise<{ welcome?: string }>;
+ searchParams: Promise<{ welcome?: string; period?: string }>;
};
export async function generateMetadata({
@@ -38,7 +45,9 @@ export async function generateMetadata({
export default async function AccountPage({ params, searchParams }: PageProps) {
const { account_id } = await params;
- const showWelcome = Object.hasOwn(await searchParams, "welcome");
+ const resolvedSearchParams = await searchParams;
+ const showWelcome = Object.hasOwn(resolvedSearchParams, "welcome");
+ const period = parsePeriod(resolvedSearchParams.period);
const account = await accountsTable.fetchById(account_id);
if (!account) {
@@ -46,8 +55,12 @@ export default async function AccountPage({ params, searchParams }: PageProps) {
}
return isOrganizationalAccount(account) ? (
-
+
) : (
-
+
);
}
diff --git a/src/components/core/SectionHeader.tsx b/src/components/core/SectionHeader.tsx
index 7d482bf9..b2374f5f 100644
--- a/src/components/core/SectionHeader.tsx
+++ b/src/components/core/SectionHeader.tsx
@@ -1,28 +1,54 @@
-import { Text, Box, Separator, Flex } from "@radix-ui/themes";
+"use client";
+
+import { useState } from "react";
+import { Text, Box, Separator, Flex, IconButton } from "@radix-ui/themes";
+import { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons";
interface SectionHeaderProps {
title: string;
children?: React.ReactNode;
rightButton?: React.ReactNode;
+ collapsible?: boolean;
+ defaultCollapsed?: boolean;
}
export function SectionHeader({
title,
children,
rightButton,
+ collapsible = false,
+ defaultCollapsed = false,
}: SectionHeaderProps) {
+ const [collapsed, setCollapsed] = useState(defaultCollapsed);
+
return (
-
- {title}
-
+
+
+ {title}
+
+ {collapsible && (
+ setCollapsed(!collapsed)}
+ >
+ {collapsed ? : }
+
+ )}
+
{rightButton}
-
-
-
- {children && {children}}
+ {!collapsed && (
+ <>
+
+
+
+ {children && {children}}
+ >
+ )}
);
}
diff --git a/src/components/features/analytics/AccountAnalyticsSection.tsx b/src/components/features/analytics/AccountAnalyticsSection.tsx
new file mode 100644
index 00000000..aa2c22ae
--- /dev/null
+++ b/src/components/features/analytics/AccountAnalyticsSection.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { Box, Card, Flex } from "@radix-ui/themes";
+import { StackedAreaChart } from "./StackedAreaChart";
+import { PeriodSelector } from "./PeriodSelector";
+import { SectionHeader } from "@/components/core/SectionHeader";
+import type { DailyAccountProductStats, Period } from "@/lib/clients/analytics";
+
+interface AccountAnalyticsSectionProps {
+ data: DailyAccountProductStats[];
+ period: Period;
+}
+
+export function AccountAnalyticsSection({
+ data,
+ period,
+}: AccountAnalyticsSectionProps) {
+ if (data.length === 0) return null;
+
+ return (
+
+
+ }
+ collapsible
+ >
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/features/analytics/PeriodSelector.tsx b/src/components/features/analytics/PeriodSelector.tsx
new file mode 100644
index 00000000..168b6746
--- /dev/null
+++ b/src/components/features/analytics/PeriodSelector.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { Flex, Button } from "@radix-ui/themes";
+import { useRouter, useSearchParams, usePathname } from "next/navigation";
+import type { Period } from "@/lib/clients/analytics";
+
+const PERIODS: { value: Period; label: string }[] = [
+ { value: 7, label: "7d" },
+ { value: 30, label: "30d" },
+ { value: 90, label: "90d" },
+];
+
+interface PeriodSelectorProps {
+ currentPeriod: Period;
+}
+
+export function PeriodSelector({ currentPeriod }: PeriodSelectorProps) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ function handlePeriodChange(period: Period) {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("period", String(period));
+ router.replace(`${pathname}?${params.toString()}`, { scroll: false });
+ }
+
+ return (
+
+ {PERIODS.map(({ value, label }) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/features/analytics/PopularFilesTable.tsx b/src/components/features/analytics/PopularFilesTable.tsx
new file mode 100644
index 00000000..217713c5
--- /dev/null
+++ b/src/components/features/analytics/PopularFilesTable.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { useState } from "react";
+import { Box, Button, Flex, Link as RadixLink, Text, Tooltip } from "@radix-ui/themes";
+import { AreaChart, Area, ResponsiveContainer } from "recharts";
+import type { PopularFile } from "@/lib/clients/analytics";
+import Link from "next/link";
+import { objectUrl } from "@/lib/urls";
+
+const DEFAULT_VISIBLE = 10;
+
+interface PopularFilesTableProps {
+ files: PopularFile[];
+ accountId: string;
+ productId: string;
+}
+
+export function PopularFilesTable({
+ files,
+ accountId,
+ productId,
+}: PopularFilesTableProps) {
+ const [showAll, setShowAll] = useState(false);
+
+ if (files.length === 0) return null;
+
+ const visibleFiles = showAll ? files : files.slice(0, DEFAULT_VISIBLE);
+ const hasMore = files.length > DEFAULT_VISIBLE;
+
+ return (
+
+
+ Popular Files
+
+
+ {visibleFiles.map((file, index) => (
+
+ ))}
+
+ {hasMore && !showAll && (
+
+
+
+ )}
+
+ );
+}
+
+function PopularFileRow({
+ file,
+ index,
+ accountId,
+ productId,
+}: {
+ file: PopularFile;
+ index: number;
+ accountId: string;
+ productId: string;
+}) {
+ const fileName = file.file_path.split("/").pop() || file.file_path;
+
+ return (
+
+
+
+
+
+
+ {fileName}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {file.total_downloads.toLocaleString()}
+
+
+ );
+}
diff --git a/src/components/features/analytics/ProductAnalytics.tsx b/src/components/features/analytics/ProductAnalytics.tsx
new file mode 100644
index 00000000..e4bb8542
--- /dev/null
+++ b/src/components/features/analytics/ProductAnalytics.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { Box, Card, Flex, Separator } from "@radix-ui/themes";
+import { SparklineChart } from "./SparklineChart";
+import { PopularFilesTable } from "./PopularFilesTable";
+import { PeriodSelector } from "./PeriodSelector";
+import { SectionHeader } from "@/components/core/SectionHeader";
+import type { DailyProductStats, Period, PopularFile } from "@/lib/clients/analytics";
+
+interface ProductAnalyticsProps {
+ data: DailyProductStats[];
+ popularFiles: PopularFile[];
+ accountId: string;
+ productId: string;
+ period: Period;
+}
+
+export function ProductAnalytics({ data, popularFiles, accountId, productId, period }: ProductAnalyticsProps) {
+ if (data.length === 0 && popularFiles.length === 0) return null;
+
+ const totalDownloads = data.reduce((sum, d) => sum + d.downloads, 0);
+ const totalBytes = data.reduce((sum, d) => sum + d.bytes, 0);
+ const dateRange = formatDateRange(data);
+
+ return (
+
+ }
+ collapsible
+ defaultCollapsed
+ >
+
+
+
+
+ {popularFiles.length > 0 && (
+
+
+
+
+ )}
+
+
+ );
+}
+
+function formatDateRange(data: DailyProductStats[]): string {
+ if (data.length === 0) return "";
+ const first = new Date(data[0].date);
+ const last = new Date(data[data.length - 1].date);
+ const fmt = (d: Date) =>
+ d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
+ return `${fmt(first)} – ${fmt(last)}`;
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ const value = bytes / Math.pow(1024, i);
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`;
+}
diff --git a/src/components/features/analytics/SparklineChart.tsx b/src/components/features/analytics/SparklineChart.tsx
new file mode 100644
index 00000000..940400c0
--- /dev/null
+++ b/src/components/features/analytics/SparklineChart.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { AreaChart, Area, ResponsiveContainer, Tooltip } from "recharts";
+import { Box, Flex, Heading, Text } from "@radix-ui/themes";
+import type { DailyProductStats } from "@/lib/clients/analytics";
+
+interface SparklineChartProps {
+ data: DailyProductStats[];
+ dataKey: "downloads" | "bytes";
+ label: string;
+ total: string;
+ dateRange: string;
+}
+
+export function SparklineChart({
+ data,
+ dataKey,
+ label,
+ total,
+ dateRange,
+}: SparklineChartProps) {
+ return (
+
+
+
+ {label} · {dateRange}
+
+ {total}
+
+
+
+
+
+
+
+
+
+
+ {
+ const d = new Date(label);
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
+ }}
+ formatter={(value) => {
+ const num = typeof value === "number" ? value : 0;
+ return dataKey === "bytes"
+ ? [formatBytes(num), "Bytes"]
+ : [num.toLocaleString(), "Downloads"];
+ }}
+ />
+
+
+
+
+
+ );
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ const value = bytes / Math.pow(1024, i);
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`;
+}
diff --git a/src/components/features/analytics/StackedAreaChart.tsx b/src/components/features/analytics/StackedAreaChart.tsx
new file mode 100644
index 00000000..a1b8d4f8
--- /dev/null
+++ b/src/components/features/analytics/StackedAreaChart.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import {
+ AreaChart,
+ Area,
+ XAxis,
+ YAxis,
+ Tooltip,
+ ResponsiveContainer,
+ Legend,
+} from "recharts";
+import { Box, Heading } from "@radix-ui/themes";
+import type { DailyAccountProductStats } from "@/lib/clients/analytics";
+
+const COLORS = [
+ "var(--accent-9)",
+ "var(--cyan-9)",
+ "var(--orange-9)",
+ "var(--green-9)",
+ "var(--pink-9)",
+ "var(--yellow-9)",
+ "var(--blue-9)",
+ "var(--red-9)",
+];
+
+interface StackedAreaChartProps {
+ data: DailyAccountProductStats[];
+ dataKey: "downloads" | "bytes";
+ label: string;
+}
+
+interface PivotedRow {
+ date: string;
+ [productId: string]: string | number;
+}
+
+export function StackedAreaChart({ data, dataKey, label }: StackedAreaChartProps) {
+ if (data.length === 0) return null;
+
+ const productIds = [...new Set(data.map((d) => d.product_id))];
+
+ const byDate = new Map();
+ for (const row of data) {
+ if (!byDate.has(row.date)) {
+ byDate.set(row.date, { date: row.date });
+ }
+ byDate.get(row.date)![row.product_id] = row[dataKey];
+ }
+
+ const pivoted = [...byDate.values()].map((row) => {
+ for (const pid of productIds) {
+ if (!(pid in row)) row[pid] = 0;
+ }
+ return row;
+ });
+
+ return (
+
+
+ {label}
+
+
+
+
+ {
+ const d = new Date(val);
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
+ }}
+ />
+
+ dataKey === "bytes" ? formatBytesShort(val) : val.toLocaleString()
+ }
+ width={60}
+ />
+ {
+ const d = new Date(label);
+ return d.toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ }}
+ formatter={(value, name) =>
+ [
+ typeof value === "number"
+ ? dataKey === "bytes"
+ ? formatBytes(value)
+ : value.toLocaleString()
+ : String(value ?? ""),
+ name,
+ ] as [string, typeof name]
+ }
+ />
+
+ {productIds.map((pid, i) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ const value = bytes / Math.pow(1024, i);
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`;
+}
+
+function formatBytesShort(bytes: number): string {
+ if (bytes === 0) return "0";
+ const units = ["B", "K", "M", "G", "T"];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ const value = bytes / Math.pow(1024, i);
+ return `${value.toFixed(0)}${units[i]}`;
+}
diff --git a/src/components/features/analytics/index.ts b/src/components/features/analytics/index.ts
new file mode 100644
index 00000000..db77a081
--- /dev/null
+++ b/src/components/features/analytics/index.ts
@@ -0,0 +1,5 @@
+export { SparklineChart } from "./SparklineChart";
+export { StackedAreaChart } from "./StackedAreaChart";
+export { PeriodSelector } from "./PeriodSelector";
+export { ProductAnalytics } from "./ProductAnalytics";
+export { AccountAnalyticsSection } from "./AccountAnalyticsSection";
diff --git a/src/components/features/profiles/IndividualProfile.tsx b/src/components/features/profiles/IndividualProfile.tsx
index ca03fb0d..7f27263e 100644
--- a/src/components/features/profiles/IndividualProfile.tsx
+++ b/src/components/features/profiles/IndividualProfile.tsx
@@ -19,6 +19,8 @@ import { EmailVerificationStatus } from "./EmailVerificationStatus";
import { AvatarLinkCompact, EditButton } from "@/components/core";
import { EmailVerificationCallout } from "../auth/EmailVerificationCallout";
import { WelcomeCallout } from "./WelcomeCallout";
+import { AccountAnalyticsSection } from "../analytics/AccountAnalyticsSection";
+import type { DailyAccountProductStats, Period } from "@/lib/clients/analytics";
interface IndividualProfileProps {
account: IndividualAccount;
@@ -28,6 +30,8 @@ interface IndividualProfileProps {
organizations: OrganizationalAccount[];
showWelcome?: boolean;
canEdit: boolean;
+ analyticsData?: DailyAccountProductStats[];
+ analyticsPeriod?: Period;
}
export function IndividualProfile({
@@ -38,6 +42,8 @@ export function IndividualProfile({
organizations,
showWelcome = false,
canEdit,
+ analyticsData,
+ analyticsPeriod = 7,
}: IndividualProfileProps) {
const primaryEmail = account.emails?.find((email) => email.is_primary);
return (
@@ -120,6 +126,13 @@ export function IndividualProfile({
)}
+ {analyticsData && analyticsData.length > 0 && (
+
+ )}
+
{ownedProducts.length > 0 && (
diff --git a/src/components/features/profiles/OrganizationProfile.tsx b/src/components/features/profiles/OrganizationProfile.tsx
index 39a85922..6de29a97 100644
--- a/src/components/features/profiles/OrganizationProfile.tsx
+++ b/src/components/features/profiles/OrganizationProfile.tsx
@@ -22,6 +22,8 @@ import { WebsiteLink } from "./WebsiteLink";
import { ProfileLocation } from "./ProfileLocation";
import { editAccountProfileUrl } from "@/lib/urls";
import { EditButton } from "@/components/core";
+import { AccountAnalyticsSection } from "../analytics/AccountAnalyticsSection";
+import type { DailyAccountProductStats, Period } from "@/lib/clients/analytics";
interface OrganizationProfileProps {
account: OrganizationalAccount;
@@ -30,6 +32,8 @@ interface OrganizationProfileProps {
admins: IndividualAccount[];
members: IndividualAccount[];
canEdit: boolean;
+ analyticsData?: DailyAccountProductStats[];
+ analyticsPeriod?: Period;
}
export function OrganizationProfile({
@@ -39,6 +43,8 @@ export function OrganizationProfile({
admins,
members,
canEdit,
+ analyticsData,
+ analyticsPeriod = 7,
}: OrganizationProfileProps) {
return (
@@ -121,6 +127,13 @@ export function OrganizationProfile({
+ {analyticsData && analyticsData.length > 0 && (
+
+ )}
+
Products
diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts
new file mode 100644
index 00000000..814bf3b1
--- /dev/null
+++ b/src/lib/clients/analytics/index.ts
@@ -0,0 +1,168 @@
+// src/lib/clients/analytics/index.ts
+
+import type { DailyProductStats, DailyAccountProductStats, Period, PopularFile } from "./types";
+import { LOGGER } from "@/lib";
+import { CONFIG } from "@/lib/config";
+
+export type { DailyProductStats, DailyAccountProductStats, Period, PopularFile } from "./types";
+
+async function queryAnalyticsEngine(sql: string): Promise {
+ const isConfigured = !!(CONFIG.analytics.accountId && CONFIG.analytics.apiToken);
+ if (!isConfigured) {
+ LOGGER.warn("Analytics engine not configured", {
+ operation: "queryAnalyticsEngine",
+ context: "checking configuration",
+ metadata: CONFIG.analytics,
+ });
+ console.warn("Analytics engine not configured.");
+ return [];
+ }
+
+ const response = await fetch(
+ `https://api.cloudflare.com/client/v4/accounts/${CONFIG.analytics.accountId}/analytics_engine/sql`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${CONFIG.analytics.apiToken}`,
+ },
+ body: sql,
+ next: { revalidate: 3600 }, // Cache for 1 hour
+ }
+ );
+
+ if (!response.ok) {
+ console.error(`Analytics Engine query failed: ${response.status} ${response.statusText}`);
+ return [];
+ }
+
+ const result = await response.json();
+ return (result.data ?? []) as T[];
+}
+
+export async function getProductAnalytics(
+ accountId: string,
+ productId: string,
+ days: Period = 7,
+ filePath?: string
+): Promise {
+ const fileFilter = filePath ? `AND blob3 = '${filePath}'` : "";
+ const sql = `
+ SELECT
+ toStartOfInterval(timestamp, INTERVAL '1' DAY) AS date,
+ SUM(_sample_interval) AS downloads,
+ SUM(_sample_interval * double1) AS bytes
+ FROM ${CONFIG.analytics.dataset}
+ WHERE blob1 = '${accountId}'
+ AND blob2 = '${productId}'
+ ${fileFilter}
+ AND timestamp >= NOW() - INTERVAL '${days}' DAY
+ GROUP BY date
+ ORDER BY date
+ `;
+
+ const rows = await queryAnalyticsEngine<{
+ date: string;
+ downloads: number;
+ bytes: number;
+ }>(sql);
+
+ LOGGER.debug("Queried data", {
+ operation: "getProductAnalytics",
+ context: "get product analytics",
+ metadata: { sql, rows },
+ });
+
+ return rows.map((row) => ({
+ date: row.date,
+ downloads: Number(row.downloads),
+ bytes: Number(row.bytes),
+ }));
+}
+
+export async function getAccountAnalytics(
+ accountId: string,
+ days: Period = 7
+): Promise {
+ const sql = `
+ SELECT
+ blob2 AS product_id,
+ toStartOfInterval(timestamp, INTERVAL '1' DAY) AS date,
+ SUM(_sample_interval) AS downloads,
+ SUM(_sample_interval * double1) AS bytes
+ FROM ${CONFIG.analytics.dataset}
+ WHERE blob1 = '${accountId}'
+ AND timestamp >= NOW() - INTERVAL '${days}' DAY
+ GROUP BY product_id, date
+ ORDER BY date
+ `;
+
+ const rows = await queryAnalyticsEngine<{
+ product_id: string;
+ date: string;
+ downloads: number;
+ bytes: number;
+ }>(sql);
+
+ LOGGER.debug("Queried data", {
+ operation: "getAccountAnalytics",
+ context: "get account analytics",
+ metadata: { sql, rows },
+ });
+
+ return rows.map((row) => ({
+ product_id: row.product_id,
+ date: row.date,
+ downloads: Number(row.downloads),
+ bytes: Number(row.bytes),
+ }));
+}
+
+export async function getPopularFiles(
+ accountId: string,
+ productId: string,
+ days: Period = 7
+): Promise {
+ const sql = `
+ SELECT
+ blob3 AS file_path,
+ toStartOfInterval(timestamp, INTERVAL '1' DAY) AS date,
+ SUM(_sample_interval) AS downloads
+ FROM ${CONFIG.analytics.dataset}
+ WHERE blob1 = '${accountId}'
+ AND blob2 = '${productId}'
+ AND timestamp >= NOW() - INTERVAL '${days}' DAY
+ GROUP BY file_path, date
+ ORDER BY file_path, date
+ `;
+
+ const rows = await queryAnalyticsEngine<{
+ file_path: string;
+ date: string;
+ downloads: number;
+ }>(sql);
+
+ LOGGER.debug("Queried popular files data", {
+ operation: "getPopularFiles",
+ context: "get popular files analytics",
+ metadata: { sql, rowCount: rows.length },
+ });
+
+ // Group by file_path, compute totals, sort by total downloads descending
+ const byFile = new Map();
+
+ for (const row of rows) {
+ const downloads = Number(row.downloads);
+ const entry = byFile.get(row.file_path) ?? { daily: [], total: 0 };
+ entry.daily.push({ date: row.date, downloads });
+ entry.total += downloads;
+ byFile.set(row.file_path, entry);
+ }
+
+ return Array.from(byFile.entries())
+ .map(([file_path, { daily, total }]) => ({
+ file_path,
+ total_downloads: total,
+ daily,
+ }))
+ .sort((a, b) => b.total_downloads - a.total_downloads);
+}
diff --git a/src/lib/clients/analytics/types.ts b/src/lib/clients/analytics/types.ts
new file mode 100644
index 00000000..beddc428
--- /dev/null
+++ b/src/lib/clients/analytics/types.ts
@@ -0,0 +1,22 @@
+// src/lib/clients/analytics/types.ts
+
+export interface DailyProductStats {
+ date: string; // ISO date string YYYY-MM-DD
+ downloads: number;
+ bytes: number;
+}
+
+export interface DailyAccountProductStats {
+ product_id: string;
+ date: string;
+ downloads: number;
+ bytes: number;
+}
+
+export type Period = 7 | 30 | 90;
+
+export interface PopularFile {
+ file_path: string;
+ total_downloads: number;
+ daily: { date: string; downloads: number }[];
+}
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 5e7de3f6..b390ebfa 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -63,6 +63,13 @@ export const CONFIG = {
},
},
+ // Cloudflare Analytics Engine configuration
+ analytics: {
+ accountId: process.env.CF_ANALYTICS_ACCOUNT_ID,
+ apiToken: process.env.CF_ANALYTICS_API_TOKEN,
+ dataset: process.env.CF_ANALYTICS_DATASET ?? "source_data_proxy_production",
+ },
+
// Location WebSocket for live globe
locationWs: {
url: process.env.NEXT_PUBLIC_LOCATION_WS_URL,