Skip to content

Commit 5ff1a23

Browse files
replicas-connector[bot]claudeconnortbot
authored
feat(bifrost): Add projection bars to stats page bar charts (#5433)
* feat(bifrost): Add projection bars to stats page bar charts Add semi-transparent gray projection bars to the last/current time bucket on all bar charts in the Bifrost stats page. The projection is calculated using a blend of: 1. Simple projection based on current progress through the time bucket 2. Historical average from previous buckets This feature helps users understand what the final total is likely to be for the in-progress time bucket. Changes: - Add projectionUtils.ts with calculation functions - Update ModelUsageChart with projection bars - Update MarketShareChart with projection bars (for token projections) - Update ProviderUsageChart with projection bars - Enhanced tooltips to show projected values for the last bar 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Co-Authored-By: Connor Loi <loiconnor8@gmail.com> * fix(bifrost): Improve projection algorithm and use Tailwind color - Use linear regression for trend-based projection instead of simple averaging - Take the max of trend projection and simple extrapolation, then blend - This ensures projections follow growth trends rather than pulling toward average - Move projection color to CHART_COLORS.projection using Tailwind muted-foreground hue - Remove hardcoded PROJECTION_COLOR constants from individual chart files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(bifrost): Simplify projection algorithm - Use linear regression on previous complete buckets for trend trajectory - Only use pace-based projection if it's HIGHER than trend - This accounts for 4-hour cache causing pace to typically be behind - Removed complex blending logic in favor of simple max(trend, pace) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: Remove unnecessary comments from chartColors.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: Remove unnecessary comments from chart files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * rm unused * refactor(bifrost): Remove shouldShowProjection function Always show projection for the last bar. The shouldShowProjection function was unnecessary - projection values should always be calculated and displayed for the last bar. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Connor Loi <loiconnor8@gmail.com>
1 parent af0976e commit 5ff1a23

File tree

5 files changed

+364
-50
lines changed

5 files changed

+364
-50
lines changed

bifrost/app/stats/MarketShareChart.tsx

Lines changed: 87 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ import {
1111
ResponsiveContainer,
1212
} from "recharts";
1313
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
14-
import { CHART_COLOR_PALETTE } from "@/lib/chartColors";
14+
import { CHART_COLOR_PALETTE, CHART_COLORS } from "@/lib/chartColors";
1515
import { Skeleton } from "@/components/ui/skeleton";
1616
import { formatTokens, formatTooltipDate } from "@/utils/formatters";
17+
import {
18+
calculateProjection,
19+
calculateTimeProgress,
20+
} from "@/utils/projectionUtils";
1721

1822
interface AuthorTokens {
1923
author: string;
@@ -29,8 +33,10 @@ interface TimeSeriesDataPoint {
2933
interface MarketShareChartProps {
3034
data: TimeSeriesDataPoint[];
3135
isLoading: boolean;
36+
timeframe?: "24h" | "7d" | "30d" | "3m" | "1y";
3237
}
3338

39+
3440
function formatTimeLabel(time: string): string {
3541
const date = new Date(time);
3642
return date.toLocaleDateString([], { month: "short", day: "numeric" });
@@ -46,37 +52,47 @@ interface CustomTooltipProps {
4652
}>;
4753
chartConfig: ChartConfig;
4854
rawData: TimeSeriesDataPoint[];
55+
projectedTokens?: Record<string, number>;
4956
}
5057

5158
function CustomTooltip({
5259
active,
5360
payload,
5461
chartConfig,
5562
rawData,
63+
projectedTokens,
5664
}: CustomTooltipProps) {
5765
if (!active || !payload?.length) return null;
5866

5967
const rawTime = payload[0]?.payload?.rawTime as string | undefined;
68+
const isLastBar = payload[0]?.payload?.isLastBar as boolean | undefined;
6069
const originalPoint = rawData.find((p) => p.time === rawTime);
6170
const sortedPayload = [...payload]
62-
.filter((item) => item.value > 0)
71+
.filter((item) => item.value > 0 && !item.dataKey.endsWith("_projection"))
6372
.sort((a, b) => b.value - a.value);
6473

74+
const hasProjections = isLastBar && projectedTokens;
75+
6576
return (
6677
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 shadow-lg rounded-lg p-3 min-w-[220px]">
6778
<div className="mb-3">
6879
<span className="text-sm font-medium text-foreground">
6980
{rawTime ? formatTooltipDate(rawTime) : ""}
7081
</span>
82+
{hasProjections && (
83+
<span className="ml-2 text-xs text-gray-400">(projected)</span>
84+
)}
7185
</div>
7286
<div className="space-y-2">
7387
{sortedPayload.map((item) => {
74-
const author = chartConfig[item.dataKey]?.label || item.dataKey;
88+
const authorKey = item.dataKey;
89+
const author = chartConfig[authorKey]?.label || authorKey;
7590
const originalAuthor = originalPoint?.authors.find(
76-
(a) => a.author === author
91+
(a) => a.author === authorKey
7792
);
7893
const tokens = originalAuthor?.totalTokens ?? 0;
7994
const percentage = item.value;
95+
const projectedAddition = hasProjections ? (projectedTokens[authorKey] ?? 0) : 0;
8096

8197
return (
8298
<div
@@ -92,9 +108,16 @@ function CustomTooltip({
92108
{author}
93109
</span>
94110
</div>
95-
<span className="text-xs font-medium tabular-nums text-gray-900 dark:text-gray-100">
96-
{formatTokens(tokens)} ({percentage.toFixed(1)}%)
97-
</span>
111+
<div className="flex items-center gap-1">
112+
<span className="text-xs font-medium tabular-nums text-gray-900 dark:text-gray-100">
113+
{formatTokens(tokens)} ({percentage.toFixed(1)}%)
114+
</span>
115+
{projectedAddition > 0 && (
116+
<span className="text-xs tabular-nums text-gray-400">
117+
{formatTokens(tokens + projectedAddition)}
118+
</span>
119+
)}
120+
</div>
98121
</div>
99122
);
100123
})}
@@ -103,31 +126,62 @@ function CustomTooltip({
103126
);
104127
}
105128

106-
export function MarketShareChart({ data, isLoading }: MarketShareChartProps) {
107-
const { chartData, authors, chartConfig } = useMemo(() => {
129+
export function MarketShareChart({ data, isLoading, timeframe = "1y" }: MarketShareChartProps) {
130+
const { chartData, authors, chartConfig, projectedTokens } = useMemo(() => {
108131
const authorSet = new Set<string>();
109132
data.forEach((point) => {
110133
point.authors.forEach((a) => authorSet.add(a.author));
111134
});
112135
const authors = Array.from(authorSet);
113136

114-
const chartData = data.map((point) => {
115-
const entry: Record<string, string | number> = {
137+
const lastTimestamp = data.length > 0 ? data[data.length - 1].time : "";
138+
const timeProgress = lastTimestamp
139+
? calculateTimeProgress(lastTimestamp, timeframe)
140+
: 0;
141+
142+
const projectedTokens: Record<string, number> = {};
143+
authors.forEach((author) => {
144+
const authorTokenValues = data.map((p) => {
145+
const a = p.authors.find((a) => a.author === author);
146+
return a?.totalTokens ?? 0;
147+
});
148+
projectedTokens[author] = calculateProjection(authorTokenValues, timeProgress);
149+
});
150+
151+
const chartData = data.map((point, index) => {
152+
const entry: Record<string, string | number | boolean> = {
116153
time: formatTimeLabel(point.time),
117154
rawTime: point.time,
155+
isLastBar: index === data.length - 1,
118156
};
119157

120158
const totalPercentage = point.authors.reduce(
121159
(sum, a) => sum + a.percentage,
122160
0
123161
);
124162

163+
const isLastBar = index === data.length - 1;
164+
let totalProjectedTokens = 0;
165+
if (isLastBar) {
166+
const currentTotal = point.authors.reduce((sum, a) => sum + a.totalTokens, 0);
167+
const projectedAdditions = Object.values(projectedTokens).reduce((sum, v) => sum + v, 0);
168+
totalProjectedTokens = currentTotal + projectedAdditions;
169+
}
170+
125171
authors.forEach((author) => {
126172
const found = point.authors.find((a) => a.author === author);
127173
const rawPercentage = found?.percentage ?? 0;
128174
const normalizedPercentage =
129175
totalPercentage > 0 ? (rawPercentage / totalPercentage) * 100 : 0;
130176
entry[author] = normalizedPercentage;
177+
178+
if (isLastBar && totalProjectedTokens > 0) {
179+
const projectedAddition = projectedTokens[author] ?? 0;
180+
const projectedPercentageAddition = (projectedAddition / totalProjectedTokens) * 100;
181+
entry[`${author}_projection`] = projectedPercentageAddition;
182+
} else {
183+
entry[`${author}_projection`] = 0;
184+
}
131185
});
132186
return entry;
133187
});
@@ -138,10 +192,14 @@ export function MarketShareChart({ data, isLoading }: MarketShareChartProps) {
138192
label: author,
139193
color: CHART_COLOR_PALETTE[index % CHART_COLOR_PALETTE.length],
140194
};
195+
chartConfig[`${author}_projection`] = {
196+
label: `${author} (projected)`,
197+
color: CHART_COLORS.projection,
198+
};
141199
});
142200

143-
return { chartData, authors, chartConfig };
144-
}, [data]);
201+
return { chartData, authors, chartConfig, projectedTokens };
202+
}, [data, timeframe]);
145203

146204
if (isLoading) {
147205
return <Skeleton className="h-[400px] w-full" />;
@@ -184,7 +242,13 @@ export function MarketShareChart({ data, isLoading }: MarketShareChartProps) {
184242
/>
185243
<Tooltip
186244
cursor={{ fill: "rgba(0, 0, 0, 0.03)" }}
187-
content={<CustomTooltip chartConfig={chartConfig} rawData={data} />}
245+
content={
246+
<CustomTooltip
247+
chartConfig={chartConfig}
248+
rawData={data}
249+
projectedTokens={projectedTokens}
250+
/>
251+
}
188252
/>
189253
{authors.map((author, index) => (
190254
<Bar
@@ -195,6 +259,15 @@ export function MarketShareChart({ data, isLoading }: MarketShareChartProps) {
195259
radius={0}
196260
/>
197261
))}
262+
{authors.map((author) => (
263+
<Bar
264+
key={`${author}_projection`}
265+
dataKey={`${author}_projection`}
266+
stackId="a"
267+
fill={CHART_COLORS.projection}
268+
radius={0}
269+
/>
270+
))}
198271
</BarChart>
199272
</ResponsiveContainer>
200273
</ChartContainer>

bifrost/app/stats/ModelUsageChart.tsx

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@ import {
1111
ResponsiveContainer,
1212
} from "recharts";
1313
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
14-
import { CHART_COLOR_PALETTE } from "@/lib/chartColors";
14+
import { CHART_COLOR_PALETTE, CHART_COLORS } from "@/lib/chartColors";
1515
import { Skeleton } from "@/components/ui/skeleton";
1616
import {
1717
formatTokens,
1818
formatTimeLabel,
1919
formatTooltipDate,
2020
} from "@/utils/formatters";
21+
import {
22+
calculateProjection,
23+
calculateTimeProgress,
24+
} from "@/utils/projectionUtils";
2125

2226
interface ModelTokens {
2327
model: string;
@@ -54,49 +58,91 @@ function CustomTooltip({ active, payload, chartConfig }: CustomTooltipProps) {
5458
if (!active || !payload?.length) return null;
5559

5660
const rawTime = payload[0]?.payload?.rawTime as string | undefined;
57-
const sortedPayload = [...payload]
58-
.filter((item) => item.value > 0)
59-
.sort((a, b) => b.value - a.value);
60-
const total = sortedPayload.reduce((sum, item) => sum + item.value, 0);
61+
const isLastBar = payload[0]?.payload?.isLastBar as boolean | undefined;
62+
63+
const modelValues: Record<string, { actual: number; projection: number; fill: string }> = {};
64+
65+
payload.forEach((item) => {
66+
if (item.value === 0) return;
67+
68+
const isProjection = item.dataKey.endsWith("_projection");
69+
const baseKey = isProjection ? item.dataKey.replace("_projection", "") : item.dataKey;
70+
71+
if (!modelValues[baseKey]) {
72+
modelValues[baseKey] = { actual: 0, projection: 0, fill: "" };
73+
}
74+
75+
if (isProjection) {
76+
modelValues[baseKey].projection = item.value;
77+
} else {
78+
modelValues[baseKey].actual = item.value;
79+
modelValues[baseKey].fill = item.fill;
80+
}
81+
});
82+
83+
const sortedEntries = Object.entries(modelValues)
84+
.filter(([, values]) => values.actual > 0 || values.projection > 0)
85+
.sort((a, b) => (b[1].actual + b[1].projection) - (a[1].actual + a[1].projection));
86+
87+
const totalActual = sortedEntries.reduce((sum, [, values]) => sum + values.actual, 0);
88+
const totalProjection = sortedEntries.reduce((sum, [, values]) => sum + values.projection, 0);
6189

6290
return (
6391
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 shadow-lg rounded-lg p-3 min-w-[220px]">
6492
<div className="mb-3">
6593
<span className="text-sm font-medium text-foreground">
6694
{rawTime ? formatTooltipDate(rawTime) : ""}
6795
</span>
96+
{isLastBar && totalProjection > 0 && (
97+
<span className="ml-2 text-xs text-gray-400">(projected)</span>
98+
)}
6899
</div>
69100
<div className="space-y-2">
70-
{sortedPayload.map((item) => (
101+
{sortedEntries.map(([key, values]) => (
71102
<div
72-
key={item.dataKey}
103+
key={key}
73104
className="flex items-center justify-between gap-4"
74105
>
75106
<div className="flex items-center gap-2">
76107
<div
77108
className="w-1 h-4 rounded-sm"
78-
style={{ backgroundColor: item.fill }}
109+
style={{ backgroundColor: values.fill }}
79110
/>
80111
<span className="text-sm text-gray-700 dark:text-gray-300">
81-
{chartConfig[item.dataKey]?.label || item.dataKey}
112+
{chartConfig[key]?.label || key}
82113
</span>
83114
</div>
84-
<span className="text-xs font-medium tabular-nums text-gray-900 dark:text-gray-100">
85-
{formatTokens(item.value)}
86-
</span>
115+
<div className="flex items-center gap-1">
116+
<span className="text-xs font-medium tabular-nums text-gray-900 dark:text-gray-100">
117+
{formatTokens(values.actual)}
118+
</span>
119+
{values.projection > 0 && (
120+
<span className="text-xs tabular-nums text-gray-400">
121+
{formatTokens(values.actual + values.projection)}
122+
</span>
123+
)}
124+
</div>
87125
</div>
88126
))}
89127
</div>
90128
<div className="border-t border-gray-200 dark:border-gray-700 mt-3 pt-2 flex justify-between">
91129
<span className="text-xs text-gray-500">Total</span>
92-
<span className="text-xs font-medium tabular-nums text-gray-900 dark:text-gray-100">
93-
{formatTokens(total)}
94-
</span>
130+
<div className="flex items-center gap-1">
131+
<span className="text-xs font-medium tabular-nums text-gray-900 dark:text-gray-100">
132+
{formatTokens(totalActual)}
133+
</span>
134+
{totalProjection > 0 && (
135+
<span className="text-xs tabular-nums text-gray-400">
136+
{formatTokens(totalActual + totalProjection)}
137+
</span>
138+
)}
139+
</div>
95140
</div>
96141
</div>
97142
);
98143
}
99144

145+
100146
export function ModelUsageChart({
101147
data,
102148
isLoading,
@@ -109,14 +155,32 @@ export function ModelUsageChart({
109155
});
110156
const models = Array.from(modelSet);
111157

112-
const chartData = data.map((point) => {
113-
const entry: Record<string, string | number> = {
158+
const lastTimestamp = data.length > 0 ? data[data.length - 1].time : "";
159+
const timeProgress = lastTimestamp
160+
? calculateTimeProgress(lastTimestamp, timeframe)
161+
: 0;
162+
163+
const chartData = data.map((point, index) => {
164+
const entry: Record<string, string | number | boolean> = {
114165
time: formatTimeLabel(point.time, timeframe),
115166
rawTime: point.time,
167+
isLastBar: index === data.length - 1,
116168
};
117169
models.forEach((model) => {
118170
const found = point.models.find((m) => m.model === model);
119-
entry[sanitizeModelName(model)] = found?.totalTokens ?? 0;
171+
const value = found?.totalTokens ?? 0;
172+
entry[sanitizeModelName(model)] = value;
173+
174+
if (index === data.length - 1) {
175+
const modelValues = data.map((p) => {
176+
const m = p.models.find((m) => m.model === model);
177+
return m?.totalTokens ?? 0;
178+
});
179+
const projectionAddition = calculateProjection(modelValues, timeProgress);
180+
entry[`${sanitizeModelName(model)}_projection`] = projectionAddition;
181+
} else {
182+
entry[`${sanitizeModelName(model)}_projection`] = 0;
183+
}
120184
});
121185
return entry;
122186
});
@@ -127,6 +191,10 @@ export function ModelUsageChart({
127191
label: model,
128192
color: CHART_COLOR_PALETTE[index % CHART_COLOR_PALETTE.length],
129193
};
194+
chartConfig[`${sanitizeModelName(model)}_projection`] = {
195+
label: `${model} (projected)`,
196+
color: CHART_COLORS.projection,
197+
};
130198
});
131199

132200
return { chartData, models, chartConfig };
@@ -181,6 +249,15 @@ export function ModelUsageChart({
181249
radius={0}
182250
/>
183251
))}
252+
{models.map((model) => (
253+
<Bar
254+
key={`${model}_projection`}
255+
dataKey={`${sanitizeModelName(model)}_projection`}
256+
stackId="a"
257+
fill={CHART_COLORS.projection}
258+
radius={0}
259+
/>
260+
))}
184261
</BarChart>
185262
</ResponsiveContainer>
186263
</ChartContainer>

0 commit comments

Comments
 (0)