Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
251 changes: 251 additions & 0 deletions app/(main)/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
"use client";

import { useMemo, useState } from "react";
import { PageHeader, Sidebar } from "@/app/components";
import { InfoTooltip, Select } from "@/app/components/ui";
import { toSelectOptions } from "@/app/lib/utils/selectOptions";
import { useApp } from "@/app/lib/context/AppContext";
import { useAuth } from "@/app/lib/context/AuthContext";
import { useAnalyticsChart } from "@/app/hooks/useAnalyticsChart";
import { useAnalyticsTotals } from "@/app/hooks/useAnalyticsTotals";
import {
AnalyticsChartCard,
AnalyticsTotalsRow,
MonthYearPicker,
} from "@/app/components/analytics";
import {
ANALYTICS_GROUP_BY_OPTIONS,
ANALYTICS_METRIC_OPTIONS,
ANALYTICS_MODALITY_OPTIONS,
PROVIDES_OPTIONS,
} from "@/app/lib/constants";
import {
AnalyticsChartFilters,
AnalyticsGroupBy,
AnalyticsMetric,
AnalyticsModality,
} from "@/app/lib/types/analytics";

function FilterField({
label,
className,
info,
children,
}: {
label: string;
className?: string;
info?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className={className}>
<label className="flex items-center text-[12px] font-medium mb-1 text-text-secondary">
<span>{label}</span>
{info && <InfoTooltip text={info} />}
</label>
{children}
</div>
);
}

export default function AnalyticsPage() {
const { sidebarCollapsed } = useApp();
const { isAuthenticated, isHydrated } = useAuth();
const [metric, setMetric] = useState<AnalyticsMetric>("cost_all");
const [groupBy, setGroupBy] = useState<AnalyticsGroupBy>("provider");
const [modality, setModality] = useState<AnalyticsModality | "">("");
const [provider, setProvider] = useState("");
const [fromMonth, setFromMonth] = useState("");
const [toMonth, setToMonth] = useState("");

const filters: AnalyticsChartFilters = useMemo(
() => ({
metric,
group_by: groupBy,
modality: modality || undefined,
provider: provider || undefined,
from_month: fromMonth || undefined,
to_month: toMonth || undefined,
}),
[metric, groupBy, modality, provider, fromMonth, toMonth],
);

const { data, isLoading, error } = useAnalyticsChart(filters);

const totalsFilters = useMemo(
() => ({
modality: modality || undefined,
provider: provider || undefined,
from_month: fromMonth || undefined,
to_month: toMonth || undefined,
}),
[modality, provider, fromMonth, toMonth],
);
const {
totals,
isLoading: isTotalsLoading,
error: totalsError,
} = useAnalyticsTotals(totalsFilters);

const metricLabel =
ANALYTICS_METRIC_OPTIONS.find((m) => m.value === metric)?.label ?? metric;

const isReady = isHydrated;

return (
<div className="w-full h-screen flex flex-col bg-bg-secondary">
<div className="flex flex-1 overflow-hidden">
<Sidebar collapsed={sidebarCollapsed} activeRoute="/analytics" />

<div className="flex-1 flex flex-col overflow-hidden bg-bg-primary">
<PageHeader
title="Analytics"
subtitle="Track cost, request volume and token usage across providers, modalities and projects"
/>

{!isReady ? null : !isAuthenticated ? (
<div className="flex-1 flex items-center justify-center px-6">
<p className="text-sm text-text-secondary">
Log in to view analytics.
</p>
</div>
) : (
<div className="flex-1 overflow-y-auto">
<div className="sticky top-0 z-10 bg-[#FAFAFA] border-b border-border px-4 sm:px-6 py-3">
<div className="flex flex-wrap lg:flex-nowrap items-end gap-x-3 gap-y-2">
<FilterField
label="Metric"
className="flex-1 basis-40 min-w-40"
info={
<>
What the chart shows.
<br />
<strong>Cost</strong> = money spent on AI (real users +
quality checks combined). <strong>Volume</strong> = how
busy your AI was (number of requests).
</>
}
>
<Select
value={metric}
onChange={(e) =>
setMetric(e.target.value as AnalyticsMetric)
}
options={toSelectOptions(ANALYTICS_METRIC_OPTIONS)}
/>
</FilterField>
<FilterField
label="Group by"
className="flex-1 basis-40 min-w-40"
info={
<>
How the chart is split up.
<br />
<strong>Total</strong> = everything as one line. Pick{" "}
<strong>Provider</strong> (who supplies the AI) or{" "}
<strong>Request type</strong> (what the AI was used for)
to see one line per group.
</>
}
>
<Select
value={groupBy}
onChange={(e) =>
setGroupBy(e.target.value as AnalyticsGroupBy)
}
options={toSelectOptions(ANALYTICS_GROUP_BY_OPTIONS)}
/>
</FilterField>
<FilterField
label="Request type"
className="flex-1 basis-40 min-w-40"
info={
<>
What the AI was used for. Pick one to see numbers for
just that kind of work:
<br />
<strong>Text → Text</strong> (chat / written replies),{" "}
<strong>Speech → Speech</strong> (voice conversations),{" "}
<strong>Speech → Text</strong> (turning audio into
written text), <strong>Text → Speech</strong> (reading
text aloud), or <strong>Other</strong>. Leave as{" "}
<em>All</em> to count everything.
</>
}
>
<Select
value={modality}
onChange={(e) =>
setModality(e.target.value as AnalyticsModality | "")
}
options={toSelectOptions(ANALYTICS_MODALITY_OPTIONS)}
placeholder="All"
/>
</FilterField>
<FilterField
label="Provider"
className="flex-1 basis-36 min-w-36"
>
<Select
value={provider}
onChange={(e) => setProvider(e.target.value)}
options={PROVIDES_OPTIONS}
placeholder="All"
/>
</FilterField>
<FilterField
label="From"
className="flex-[1.25] basis-48 min-w-44"
>
<MonthYearPicker
value={fromMonth}
onChange={setFromMonth}
placeholder="Any start"
/>
</FilterField>
<FilterField
label="To"
className="flex-[1.25] basis-48 min-w-44"
>
<MonthYearPicker
value={toMonth}
onChange={setToMonth}
placeholder="Any end"
/>
</FilterField>
{(fromMonth || toMonth) && (
<button
type="button"
onClick={() => {
setFromMonth("");
setToMonth("");
}}
className="text-[12px] font-medium text-status-error-text hover:underline cursor-pointer pb-1.5 shrink-0"
>
Clear dates
</button>
)}
</div>
</div>

<div className="px-4 sm:px-6 py-6 space-y-6">
<AnalyticsTotalsRow
totals={totals}
isLoading={isTotalsLoading}
error={totalsError}
/>

<AnalyticsChartCard
data={data}
isLoading={isLoading}
error={error}
metricLabel={metricLabel}
/>
</div>
</div>
)}
</div>
</div>
</div>
);
}
21 changes: 21 additions & 0 deletions app/api/analytics/monthly/chart/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { apiClient } from "@/app/lib/apiClient";

export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const qs = searchParams.toString();
const endpoint = `/api/v1/analytics/monthly/chart${qs ? `?${qs}` : ""}`;
const { status, data } = await apiClient(request, endpoint);
return NextResponse.json(data, { status });
} catch (error: unknown) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : String(error),
data: null,
},
Comment thread
Ayush8923 marked this conversation as resolved.
{ status: 500 },
);
}
}
2 changes: 2 additions & 0 deletions app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ChevronRightIcon,
ChevronLeftIcon,
ChatIcon,
ChartBarIcon,
} from "@/app/components/icons";
import { LoginModal } from "@/app/components/auth";
import { Branding, UserMenuPopover } from "@/app/components/user-menu";
Expand Down Expand Up @@ -119,6 +120,7 @@ export default function Sidebar({
gear: <GearIcon className="w-5 h-5" />,
shield: <ShieldCheckIcon />,
sliders: <SlidersIcon />,
chart: <ChartBarIcon />,
};

const navItems: MenuItem[] = NAV_ITEMS.filter(
Expand Down
Loading
Loading