Skip to main content
Glama
UsageByProjectChart.tsx13.3 kB
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> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/get-convex/convex-backend'

If you have feedback or need assistance with the MCP directory API, please join our Discord server