Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
81be8ad
chore: add .worktrees to gitignore
alukach Mar 25, 2026
b9a29e2
chore: add recharts dependency for analytics charts
alukach Mar 25, 2026
7ba5f32
feat: add Cloudflare Analytics Engine client
alukach Mar 25, 2026
b6bbd96
feat: add period selector, sparkline chart, and product analytics com…
alukach Mar 25, 2026
6a8e7ee
feat: add stacked area chart and account analytics section components
alukach Mar 25, 2026
09527a3
feat: integrate analytics into product and account pages
alukach Mar 25, 2026
f081c9d
chore: add analytics components barrel export
alukach Mar 25, 2026
309e708
refactor: use config
alukach Mar 25, 2026
becbdf4
Add warning for unconfigured analytics engine
alukach Mar 25, 2026
2ce0a62
add debug logging
alukach Mar 25, 2026
cc93e31
chore: log config
alukach Mar 25, 2026
d057d0d
Fix logic
alukach Mar 25, 2026
37ecddb
chore: add .worktrees/ to .gitignore
alukach Mar 25, 2026
d5faa7c
chore: add docs/plans to .gitignore
alukach Mar 26, 2026
824794d
feat: add PopularFile types for file-level analytics
alukach Mar 26, 2026
553bb7e
feat: add getPopularFiles analytics query
alukach Mar 26, 2026
1e7ef18
feat: create PopularFilesSidebar component
alukach Mar 26, 2026
4cc5df3
feat: add popular files sidebar to product layout
alukach Mar 26, 2026
520ab7a
fix: address review feedback for popular files sidebar
alukach Mar 26, 2026
1aa69d3
chore: use _sample_interval in queries
alukach Mar 26, 2026
1e63d2a
refactor: move popular files from sidebar to analytics table
alukach Mar 26, 2026
e0c9d43
feat: make analytics sections collapsible
alukach Mar 26, 2026
44525b4
Add filepath analytics
alukach Mar 26, 2026
b9a894c
Collapse analytics by default
alukach Mar 26, 2026
d61be9d
Merge branch 'main' into feat/analytics
alukach Mar 30, 2026
12e41be
chore: fix config
alukach Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 11 additions & 7 deletions src/app/(app)/[account_id]/IndividualProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -51,6 +53,8 @@ export async function IndividualProfilePage({
organizations={organizations}
showWelcome={showWelcome}
canEdit={isAuthorized(session, account, Actions.PutAccountProfile)}
analyticsData={analyticsData}
analyticsPeriod={period}
/>
);
}
8 changes: 7 additions & 1 deletion src/app/(app)/[account_id]/OrganizationProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -106,6 +110,8 @@ export async function OrganizationProfilePage({
admins={admins}
members={members}
canEdit={isAuthorized(session, account, Actions.PutAccountProfile)}
analyticsData={analyticsData}
analyticsPeriod={period}
/>
</Box>
</Container>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function AnalyticsLoading() {
return null;
}
Original file line number Diff line number Diff line change
@@ -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 (
<ProductAnalytics
data={data}
popularFiles={popularFiles}
accountId={account_id}
productId={product_id}
period={period}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ 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[] }>;
}

export default async function ProductLayout({
params,
children,
readme,
analytics,
}: ProductLayoutProps) {
// Then check if product exists
const { account_id, product_id, path } = await params;
Expand Down Expand Up @@ -60,6 +62,7 @@ export default async function ProductLayout({
<Box mt="4">
<ProductHeader product={product} />
</Box>
<Box mt="4">{analytics}</Box>
<Box mt="4">
<Dropzone product={product} prefix={prefix}>
<Card>
Expand Down
21 changes: 17 additions & 4 deletions src/app/(app)/[account_id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -38,16 +45,22 @@ 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) {
notFound();
}

return isOrganizationalAccount(account) ? (
<OrganizationProfilePage account={account} />
<OrganizationProfilePage account={account} period={period} />
) : (
<IndividualProfilePage account={account} showWelcome={showWelcome} />
<IndividualProfilePage
account={account}
showWelcome={showWelcome}
period={period}
/>
);
}
42 changes: 34 additions & 8 deletions src/components/core/SectionHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box>
<Flex justify="between" align="center">
<Text size="2" weight="bold">
{title}
</Text>
<Flex align="center" gap="2">
<Text size="2" weight="bold">
{title}
</Text>
{collapsible && (
<IconButton
size="1"
variant="ghost"
color="gray"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <ChevronDownIcon /> : <ChevronUpIcon />}
</IconButton>
)}
</Flex>
{rightButton}
</Flex>
<Box my="3">
<Separator size="4" color="gray" />
</Box>
{children && <Box>{children}</Box>}
{!collapsed && (
<>
<Box my="3">
<Separator size="4" color="gray" />
</Box>
{children && <Box>{children}</Box>}
</>
)}
</Box>
);
}
36 changes: 36 additions & 0 deletions src/components/features/analytics/AccountAnalyticsSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box mb="6">
<Card>
<SectionHeader
title="Analytics"
rightButton={<PeriodSelector currentPeriod={period} />}
collapsible
>
<Flex direction="column" gap="6">
<StackedAreaChart data={data} dataKey="downloads" label="Downloads" />
<StackedAreaChart data={data} dataKey="bytes" label="Bytes Downloaded" />
</Flex>
</SectionHeader>
</Card>
</Box>
);
}
43 changes: 43 additions & 0 deletions src/components/features/analytics/PeriodSelector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex gap="1">
{PERIODS.map(({ value, label }) => (
<Button
key={value}
size="1"
variant={currentPeriod === value ? "solid" : "soft"}
color="gray"
onClick={() => handlePeriodChange(value)}
>
{label}
</Button>
))}
</Flex>
);
}
Loading
Loading