import {
DailyMetricByProject,
DailyPerTagMetricsByProject,
} from "hooks/usageMetrics";
import { useMemo } from "react";
import groupBy from "lodash/groupBy";
import sumBy from "lodash/sumBy";
import { TeamResponse } from "generatedApi";
import { toNumericUTC } from "@common/lib/format";
import { Bar, Legend, Rectangle } from "recharts";
import { useProfile } from "api/profile";
import { useProjectById } from "api/projects";
import { UsageNoDataError } from "./TeamUsageError";
import { QuantityType, formatQuantity } from "./lib/formatQuantity";
import { DailyChart } from "./DailyChart";
import { DailyChartDetailView } from "./DailyChartDetailView";
// When there is only a data point, we have to set the bar width manually to make it appear (https://github.com/recharts/recharts/issues/3640).
// This value has been measured manually on a desktop screen size, but it should also look good in other contexts where there is only one bar.
const SINGLE_BAR_WIDTH = 91;
const MS_IN_DAY = 24 * 60 * 60 * 1000;
// Colors for projects - we'll cycle through these
const PROJECT_COLORS = [
"fill-chart-line-1",
"fill-chart-line-2",
"fill-chart-line-3",
"fill-chart-line-4",
"fill-chart-line-5",
"fill-chart-line-6",
"fill-chart-line-7",
"fill-chart-line-8",
];
// Component to render a single project name (can call hooks)
function ProjectName({ projectId }: { projectId: number | string }) {
const project = useProjectById(
projectId === "_rest" ? undefined : (projectId as number),
);
if (projectId === "_rest") {
return <>All other projects</>;
}
if (project === undefined) {
// Project is loading
return (
<span className="inline-block h-4 w-32 animate-pulse rounded bg-content-tertiary" />
);
}
return <>{project?.name || `Deleted Project (${projectId})`}</>;
}
// Custom tooltip component that renders project names
function ProjectTooltipItem({
projectId,
value,
color,
quantityType,
}: {
projectId: number | string;
value: number;
color: string;
quantityType: QuantityType;
}) {
return (
<div className="flex items-center gap-2">
<svg className="size-3 flex-shrink-0" viewBox="0 0 50 50" aria-hidden>
<circle cx="25" cy="25" r="25" className={color} />
</svg>
<span className="tabular-nums">
<ProjectName projectId={projectId} />:{" "}
{formatQuantity(value, quantityType)}
</span>
</div>
);
}
// Custom tooltip that can render project names with hooks
function ProjectChartTooltip({
active,
payload,
label,
quantityType,
colorMap,
}: {
active?: boolean;
payload?: any[];
label?: any;
quantityType: QuantityType;
colorMap: Map<string, string>;
}) {
if (!active || !payload || payload.length === 0) {
return null;
}
// Filter to items with value > 0 and extract project IDs
const items = payload
.filter((entry) => {
const value = entry.value as number;
return value > 0;
})
.reverse(); // Reverse to show highest value first
if (items.length === 0) {
return null;
}
const formattedDate = new Date(label).toLocaleDateString("en-us", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
return (
<div className="rounded-lg border bg-background-primary p-3 shadow-lg">
<div className="mb-2 font-semibold">{formattedDate}</div>
<div className="space-y-1">
{items.map((entry, index) => {
// Extract project ID from dataKey (format: "project_123")
const projectIdStr = (entry.dataKey as string).replace(
"project_",
"",
);
const projectId =
projectIdStr === "_rest" ? "_rest" : Number(projectIdStr);
const color = colorMap.get(entry.dataKey as string) || "";
return (
<ProjectTooltipItem
key={index}
projectId={projectId}
value={entry.value as number}
color={color}
quantityType={quantityType}
/>
);
})}
</div>
</div>
);
}
// Component for rendering a single project legend item
function ProjectLegendItem({
projectId,
color,
total,
}: {
projectId: number | string;
color: string;
total: number;
}) {
if (total <= 0) return null;
return (
<span className="flex items-center gap-2">
<svg className="w-4 flex-shrink-0" viewBox="0 0 50 50" aria-hidden>
<circle cx="25" cy="25" r="25" className={color} />
</svg>
<span className="max-w-80 truncate">
<ProjectName projectId={projectId} />
</span>
</span>
);
}
// Detail item that includes project ID for lazy loading
interface ProjectDetailItem {
projectId: number | string;
value: number;
color: string;
}
// Component wrapper to convert project detail items to regular detail items
function ProjectChartDetailView({
date,
items,
quantityType,
onBack,
team,
memberId,
}: {
date: number;
items: ProjectDetailItem[];
quantityType: QuantityType;
onBack: () => void;
team?: TeamResponse;
memberId?: number;
}) {
// Convert ProjectDetailItem[] to DailyChartDetailItem[] by fetching projects
const detailItems = items.map((item) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const project = useProjectById(
item.projectId === "_rest" ? undefined : (item.projectId as number),
);
return {
project: item.projectId === "_rest" ? null : (project ?? null),
value: item.value,
color: item.color,
};
});
return (
<DailyChartDetailView
date={date}
items={detailItems}
quantityType={quantityType}
onBack={onBack}
team={team}
memberId={memberId}
/>
);
}
export function UsageByProjectChart({
rows,
entity,
quantityType = "unit",
team,
selectedDate,
setSelectedDate,
}: {
rows: DailyPerTagMetricsByProject[] | DailyMetricByProject[];
entity: string;
quantityType?: QuantityType;
team?: TeamResponse;
selectedDate: number | null;
setSelectedDate: (date: number | null) => void;
}) {
const member = useProfile();
const { chartData, projectIds, totalByProject } = useMemo(() => {
// Helper to get the total value from a row (handles both data types)
const getRowTotal = (
row: DailyPerTagMetricsByProject | DailyMetricByProject,
) => {
if ("metrics" in row) {
return sumBy(row.metrics, (m) => m.value);
}
return row.value;
};
// Get all unique project IDs and sort by total usage
const byProject = groupBy(rows, (row) =>
String(
(row as DailyPerTagMetricsByProject | DailyMetricByProject).projectId,
),
);
const projectTotals = Object.entries(byProject).map(
([projectId, projectRows]) => {
const parsedId = projectId === "_rest" ? "_rest" : Number(projectId);
return {
projectId: parsedId,
total: sumBy(
projectRows as Array<
DailyPerTagMetricsByProject | DailyMetricByProject
>,
getRowTotal,
),
};
},
);
// Create quantity-sorted list for stacking (largest at bottom)
// Also used for legend display (sorted by quantity, not alphabetically)
const stackProjectIds = [...projectTotals]
.sort((a, b) => {
if (a.projectId === "_rest") return 1;
if (b.projectId === "_rest") return -1;
return b.total - a.total;
})
.map((p) => p.projectId);
const filledData = [];
const dateSet = new Set(rows.map(({ ds }) => toNumericUTC(ds)));
// Find the range of dates
const minDate = Math.min(...Array.from(dateSet));
const maxDate = Math.max(...Array.from(dateSet));
// Fill in the missing dates
for (let date = minDate; date <= maxDate; date += MS_IN_DAY) {
const dayRows = rows.filter(({ ds }) => toNumericUTC(ds) === date);
const dataPoint: any = {
dateNumeric: date,
};
// For each project, sum up all values for that day
for (const projectId of stackProjectIds) {
const projectRows = dayRows.filter((r) => r.projectId === projectId);
const total = sumBy(projectRows, getRowTotal);
dataPoint[`project_${projectId}`] = total;
}
filledData.push(dataPoint);
}
const totals = Object.fromEntries(
projectTotals.map((p) => [p.projectId, p.total]),
);
return {
chartData: filledData,
projectIds: stackProjectIds,
totalByProject: totals,
};
}, [rows]);
const colorMap = useMemo(() => {
const map = new Map<string, string>();
projectIds.forEach((projectId, index) => {
const color = PROJECT_COLORS[index % PROJECT_COLORS.length];
map.set(`project_${projectId}`, color);
});
return map;
}, [projectIds]);
// Get detail items for selected date
const detailItems = useMemo((): ProjectDetailItem[] => {
if (selectedDate === null) return [];
const dataPoint = chartData.find((d) => d.dateNumeric === selectedDate);
if (!dataPoint) return [];
return projectIds.map((projectId, index) => {
const color = PROJECT_COLORS[index % PROJECT_COLORS.length];
return {
projectId,
value: (dataPoint[`project_${projectId}`] as number) || 0,
color,
};
});
}, [selectedDate, chartData, projectIds]);
if (
!rows.some((row) => {
if ("metrics" in row) {
return row.metrics.some(({ value }) => value > 0);
}
return row.value > 0;
})
) {
return <UsageNoDataError entity={entity} />;
}
return (
<div
className={`relative overflow-hidden transition-all duration-300 ${
selectedDate !== null ? "h-[32rem]" : "h-56"
}`}
>
{/* Background chart (slides out to left when detail view is shown) */}
<div
className="absolute inset-0 transition-transform duration-300 ease-in-out"
style={{
transform:
selectedDate !== null ? "translateX(-100%)" : "translateX(0)",
}}
>
<DailyChart
data={chartData}
quantityType={quantityType}
showCategoryInTooltip
colorMap={colorMap}
yAxisWidth={quantityType === "actionCompute" ? 80 : 60}
customTooltip={(props) => (
<ProjectChartTooltip
{...props}
quantityType={quantityType}
colorMap={colorMap}
/>
)}
>
{projectIds.map((projectId, index) => {
const color = PROJECT_COLORS[index % PROJECT_COLORS.length];
return (
<Bar
key={projectId}
dataKey={`project_${projectId}`}
className={color}
name={` `} // Space for consistent tooltip formatting
barSize={chartData.length === 1 ? SINGLE_BAR_WIDTH : undefined}
isAnimationActive={false}
stackId="stack"
style={{ cursor: "pointer" }}
tabIndex={0}
onClick={(data: any) => {
if (data?.dateNumeric) {
setSelectedDate(data.dateNumeric);
}
}}
onKeyDown={(data, _idx, event) => {
if (event.key === "Enter") {
if (data?.dateNumeric) {
setSelectedDate(data.dateNumeric);
}
}
}}
shape={(props: any) => <Rectangle {...props} />}
/>
);
})}
{selectedDate === null && (
<Legend
content={() => (
<div
className="scrollbar flex max-h-20 flex-wrap gap-3 overflow-y-auto"
style={{
paddingLeft: `${quantityType === "actionCompute" ? 92 : 72}px`,
}}
>
{projectIds.map((projectId, index) => {
const color = PROJECT_COLORS[index % PROJECT_COLORS.length];
const total = totalByProject[projectId] || 0;
return (
<ProjectLegendItem
key={projectId}
projectId={projectId}
color={color}
total={total}
/>
);
})}
</div>
)}
/>
)}
</DailyChart>
</div>
{/* Detail view (slides in from right) */}
<div
className="absolute inset-0 transition-transform duration-300 ease-in-out"
style={{
transform:
selectedDate !== null ? "translateX(0)" : "translateX(100%)",
}}
>
{selectedDate !== null && (
<ProjectChartDetailView
date={selectedDate}
items={detailItems}
quantityType={quantityType}
onBack={() => setSelectedDate(null)}
team={team}
memberId={member?.id}
/>
)}
</div>
</div>
);
}