Skip to content

Commit 541ead0

Browse files
committed
feat: cost breakdown
1 parent 948168f commit 541ead0

File tree

11 files changed

+890
-0
lines changed

11 files changed

+890
-0
lines changed
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
'use client';
2+
3+
import { CalendarIcon, ChartBarIcon } from '@phosphor-icons/react';
4+
import { useMemo, useState } from 'react';
5+
import {
6+
Bar,
7+
BarChart,
8+
Legend,
9+
ResponsiveContainer,
10+
Tooltip,
11+
XAxis,
12+
YAxis,
13+
} from 'recharts';
14+
import { DateRangePicker } from '@/components/date-range-picker';
15+
import { Button } from '@/components/ui/button';
16+
import { Skeleton } from '@/components/ui/skeleton';
17+
import type { UsageResponse } from '@databuddy/shared';
18+
import { calculateOverageCost, type OverageInfo } from '../utils/billing-utils';
19+
20+
type ViewMode = 'daily' | 'cumulative';
21+
22+
import { METRIC_COLORS } from '@/components/charts/metrics-constants';
23+
24+
const EVENT_TYPE_COLORS = {
25+
event: METRIC_COLORS.pageviews.primary, // blue
26+
error: METRIC_COLORS.session_duration.primary, // red
27+
web_vitals: METRIC_COLORS.visitors.primary, // green
28+
custom_event: METRIC_COLORS.sessions.primary, // purple
29+
outgoing_link: METRIC_COLORS.bounce_rate.primary, // amber
30+
} as const;
31+
32+
interface ConsumptionChartProps {
33+
usageData?: UsageResponse;
34+
isLoading: boolean;
35+
onDateRangeChange: (startDate: string, endDate: string) => void;
36+
overageInfo: OverageInfo | null;
37+
}
38+
39+
export function ConsumptionChart({ usageData, isLoading, onDateRangeChange, overageInfo }: ConsumptionChartProps) {
40+
const [viewMode, setViewMode] = useState<ViewMode>('daily');
41+
42+
const chartData = useMemo(() => {
43+
if (!usageData?.dailyUsageByType) return [];
44+
45+
// Group the real daily usage by type data by date
46+
const dailyDataMap = new Map<string, Record<string, number>>();
47+
48+
// Initialize all dates with zero values for all event types
49+
const allDates = [...new Set(usageData.dailyUsageByType.map(row => row.date))].sort();
50+
for (const date of allDates) {
51+
dailyDataMap.set(date, {
52+
event: 0,
53+
error: 0,
54+
web_vitals: 0,
55+
custom_event: 0,
56+
outgoing_link: 0,
57+
});
58+
}
59+
60+
// Fill in the actual data from ClickHouse
61+
for (const row of usageData.dailyUsageByType) {
62+
const dayData = dailyDataMap.get(row.date);
63+
if (dayData) {
64+
dayData[row.event_category] = row.event_count;
65+
}
66+
}
67+
68+
// Convert to chart format with cumulative calculation if needed
69+
let runningTotals = Object.keys(EVENT_TYPE_COLORS).reduce((acc, key) => {
70+
acc[key] = 0;
71+
return acc;
72+
}, {} as Record<string, number>);
73+
74+
return Array.from(dailyDataMap.entries()).map(([date, eventCounts]) => {
75+
const dayData: any = {
76+
date: new Date(date).toLocaleDateString('en-US', {
77+
month: 'short',
78+
day: 'numeric',
79+
}),
80+
fullDate: date,
81+
};
82+
83+
// Use real data from ClickHouse, not approximations
84+
Object.keys(EVENT_TYPE_COLORS).forEach(eventType => {
85+
const actualAmount = eventCounts[eventType] || 0;
86+
87+
if (viewMode === 'cumulative') {
88+
runningTotals[eventType] += actualAmount;
89+
dayData[eventType] = runningTotals[eventType];
90+
} else {
91+
dayData[eventType] = actualAmount;
92+
}
93+
});
94+
95+
return dayData;
96+
});
97+
}, [usageData?.dailyUsageByType, viewMode]);
98+
99+
if (isLoading) {
100+
return (
101+
<div className="h-full flex flex-col border-b">
102+
<div className="px-6 py-4 border-b bg-muted/20">
103+
<div className="flex items-center justify-between">
104+
<Skeleton className="h-6 w-48" />
105+
<Skeleton className="h-8 w-32" />
106+
</div>
107+
</div>
108+
<div className="flex-1 px-6 py-6">
109+
<Skeleton className="h-full" />
110+
</div>
111+
</div>
112+
);
113+
}
114+
115+
if (!usageData || chartData.length === 0) {
116+
return (
117+
<div className="h-full flex flex-col border-b">
118+
<div className="px-6 py-4 border-b bg-muted/20">
119+
<div className="flex items-center gap-2">
120+
<ChartBarIcon className="h-5 w-5" weight="duotone" />
121+
<h2 className="text-lg font-semibold">Consumption Breakdown</h2>
122+
</div>
123+
</div>
124+
<div className="flex-1 px-6 py-6 flex items-center justify-center">
125+
<div className="text-center">
126+
<CalendarIcon className="mx-auto h-12 w-12 text-muted-foreground mb-4" weight="duotone" />
127+
<h3 className="text-lg font-semibold">No Data Available</h3>
128+
<p className="text-muted-foreground">No usage data found for the selected period</p>
129+
</div>
130+
</div>
131+
</div>
132+
);
133+
}
134+
135+
const maxValue = Math.max(...chartData.map(d =>
136+
Object.keys(EVENT_TYPE_COLORS).reduce((sum, key) => sum + (d[key] || 0), 0)
137+
));
138+
const yAxisMax = Math.ceil(maxValue * 1.1);
139+
140+
return (
141+
<div className="h-full flex flex-col border-b">
142+
<div className="px-6 py-4 border-b bg-muted/20">
143+
<div className="flex items-center justify-between">
144+
<div className="flex items-center gap-2">
145+
<ChartBarIcon className="h-5 w-5" weight="duotone" />
146+
<h2 className="text-lg font-semibold">Consumption Breakdown</h2>
147+
</div>
148+
<div className="flex items-center gap-2">
149+
<DateRangePicker
150+
className="w-auto"
151+
maxDate={new Date()}
152+
minDate={new Date(2020, 0, 1)}
153+
onChange={(range) => {
154+
if (range?.from && range?.to) {
155+
onDateRangeChange(
156+
range.from.toISOString().split('T')[0],
157+
range.to.toISOString().split('T')[0]
158+
);
159+
}
160+
}}
161+
value={{
162+
from: new Date(usageData.dateRange.startDate),
163+
to: new Date(usageData.dateRange.endDate),
164+
}}
165+
/>
166+
<div className="flex rounded border">
167+
<Button
168+
variant={viewMode === 'cumulative' ? 'default' : 'ghost'}
169+
size="sm"
170+
onClick={() => setViewMode('cumulative')}
171+
className="rounded-r-none border-r"
172+
>
173+
Cumulative
174+
</Button>
175+
<Button
176+
variant={viewMode === 'daily' ? 'default' : 'ghost'}
177+
size="sm"
178+
onClick={() => setViewMode('daily')}
179+
className="rounded-l-none"
180+
>
181+
Daily
182+
</Button>
183+
</div>
184+
</div>
185+
</div>
186+
</div>
187+
<div className="flex-1 px-6 py-6">
188+
<div className="h-full">
189+
<ResponsiveContainer width="100%" height="100%">
190+
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
191+
<defs>
192+
{Object.entries(EVENT_TYPE_COLORS).map(([key, color]) => (
193+
<linearGradient
194+
id={`gradient-${key}`}
195+
key={key}
196+
x1="0"
197+
x2="0"
198+
y1="0"
199+
y2="1"
200+
>
201+
<stop
202+
offset="0%"
203+
stopColor={color}
204+
stopOpacity={0.8}
205+
/>
206+
<stop
207+
offset="100%"
208+
stopColor={color}
209+
stopOpacity={0.6}
210+
/>
211+
</linearGradient>
212+
))}
213+
</defs>
214+
<XAxis
215+
dataKey="date"
216+
axisLine={{ stroke: 'var(--border)', strokeOpacity: 0.5 }}
217+
tickLine={false}
218+
tick={{
219+
fontSize: 11,
220+
fill: 'var(--muted-foreground)',
221+
fontWeight: 500,
222+
}}
223+
/>
224+
<YAxis
225+
axisLine={false}
226+
tickLine={false}
227+
tick={{ fontSize: 11, fill: 'var(--muted-foreground)', fontWeight: 500 }}
228+
width={45}
229+
domain={[0, yAxisMax]}
230+
tickFormatter={(value) => {
231+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
232+
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
233+
return value.toString();
234+
}}
235+
/>
236+
<Tooltip
237+
content={({ active, payload, label }) => {
238+
if (active && payload && payload.length) {
239+
return (
240+
<div className="min-w-[200px] rounded border border-border/50 bg-card p-4">
241+
<div className="mb-3 flex items-center gap-2 border-border/30 border-b pb-2">
242+
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
243+
<p className="font-semibold text-foreground text-sm">{label}</p>
244+
</div>
245+
<div className="space-y-2.5">
246+
{payload
247+
.filter(entry => entry.value && (entry.value as number) > 0)
248+
.map((entry, index) => {
249+
const eventType = entry.dataKey as keyof typeof EVENT_TYPE_COLORS;
250+
const color = EVENT_TYPE_COLORS[eventType];
251+
const eventCount = entry.value as number;
252+
const overageCost = usageData ? calculateOverageCost(eventCount, usageData.totalEvents, overageInfo) : 0;
253+
254+
return (
255+
<div key={index} className="group flex items-center justify-between gap-3">
256+
<div className="flex items-center gap-2.5">
257+
<div
258+
className="h-3 w-3 rounded-full shadow-sm ring-2 ring-background"
259+
style={{ backgroundColor: color }}
260+
/>
261+
<span className="font-medium text-muted-foreground text-xs capitalize">
262+
{entry.dataKey?.toString().replace('_', ' ')}
263+
</span>
264+
</div>
265+
<div className="text-right">
266+
<div className="font-bold text-foreground text-sm group-hover:text-primary">
267+
{eventCount.toLocaleString()}
268+
</div>
269+
<div className="text-xs text-muted-foreground">
270+
${overageCost.toFixed(6)}
271+
</div>
272+
</div>
273+
</div>
274+
);
275+
})}
276+
</div>
277+
</div>
278+
);
279+
}
280+
return null;
281+
}}
282+
cursor={{
283+
stroke: 'var(--primary)',
284+
strokeWidth: 1,
285+
strokeOpacity: 0.5,
286+
strokeDasharray: '4 4',
287+
}}
288+
wrapperStyle={{ outline: 'none' }}
289+
/>
290+
<Legend
291+
formatter={(value) => (
292+
<span className="text-xs font-medium text-muted-foreground capitalize">
293+
{value.replace('_', ' ')}
294+
</span>
295+
)}
296+
iconSize={10}
297+
iconType="circle"
298+
wrapperStyle={{
299+
fontSize: '12px',
300+
paddingTop: '20px',
301+
fontWeight: 500,
302+
}}
303+
/>
304+
{Object.keys(EVENT_TYPE_COLORS).map((eventType) => (
305+
<Bar
306+
key={eventType}
307+
dataKey={eventType}
308+
stackId="events"
309+
fill={`url(#gradient-${eventType})`}
310+
stroke={EVENT_TYPE_COLORS[eventType as keyof typeof EVENT_TYPE_COLORS]}
311+
strokeWidth={0.5}
312+
/>
313+
))}
314+
</BarChart>
315+
</ResponsiveContainer>
316+
</div>
317+
</div>
318+
</div>
319+
);
320+
}

0 commit comments

Comments
 (0)