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,