1// Get all charmtone colors once from computed styles
2const rootStyles = getComputedStyle(document.documentElement);
3const colors = {
4 charple: rootStyles.getPropertyValue("--charple").trim(),
5 cherry: rootStyles.getPropertyValue("--cherry").trim(),
6 julep: rootStyles.getPropertyValue("--julep").trim(),
7 urchin: rootStyles.getPropertyValue("--urchin").trim(),
8 butter: rootStyles.getPropertyValue("--butter").trim(),
9 squid: rootStyles.getPropertyValue("--squid").trim(),
10 pepper: rootStyles.getPropertyValue("--pepper").trim(),
11 tuna: rootStyles.getPropertyValue("--tuna").trim(),
12 uni: rootStyles.getPropertyValue("--uni").trim(),
13 coral: rootStyles.getPropertyValue("--coral").trim(),
14 violet: rootStyles.getPropertyValue("--violet").trim(),
15 malibu: rootStyles.getPropertyValue("--malibu").trim(),
16};
17
18const easeDuration = 500;
19const easeType = "easeOutQuart";
20
21// Helper functions
22function formatNumber(n) {
23 return new Intl.NumberFormat().format(Math.round(n));
24}
25
26function formatCompact(n) {
27 if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
28 if (n >= 1000) return (n / 1000).toFixed(1) + "k";
29 return Math.round(n).toString();
30}
31
32function formatCost(n) {
33 return "$" + n.toFixed(2);
34}
35
36function formatTime(ms) {
37 if (ms < 1000) return Math.round(ms) + "ms";
38 return (ms / 1000).toFixed(1) + "s";
39}
40
41const charpleColor = { r: 107, g: 80, b: 255 };
42const tunaColor = { r: 255, g: 109, b: 170 };
43
44function interpolateColor(ratio, alpha = 1) {
45 const r = Math.round(charpleColor.r + (tunaColor.r - charpleColor.r) * ratio);
46 const g = Math.round(charpleColor.g + (tunaColor.g - charpleColor.g) * ratio);
47 const b = Math.round(charpleColor.b + (tunaColor.b - charpleColor.b) * ratio);
48 if (alpha < 1) {
49 return `rgba(${r}, ${g}, ${b}, ${alpha})`;
50 }
51 return `rgb(${r}, ${g}, ${b})`;
52}
53
54function getTopItemsWithOthers(items, countKey, labelKey, topN = 10) {
55 const topItems = items.slice(0, topN);
56 const otherItems = items.slice(topN);
57 const otherCount = otherItems.reduce((sum, item) => sum + item[countKey], 0);
58 const displayItems = [...topItems];
59 if (otherItems.length > 0) {
60 const otherItem = { [countKey]: otherCount, [labelKey]: "others" };
61 displayItems.push(otherItem);
62 }
63 return displayItems;
64}
65
66// Populate summary cards
67document.getElementById("total-sessions").textContent = formatNumber(
68 stats.total.total_sessions,
69);
70document.getElementById("total-messages").textContent = formatCompact(
71 stats.total.total_messages,
72);
73document.getElementById("total-tokens").textContent = formatCompact(
74 stats.total.total_tokens,
75);
76document.getElementById("total-cost").textContent = formatCost(
77 stats.total.total_cost,
78);
79document.getElementById("avg-tokens").innerHTML =
80 '<span title="Average">x̅</span> ' +
81 formatCompact(stats.total.avg_tokens_per_session);
82document.getElementById("avg-response").innerHTML =
83 '<span title="Average">x̅</span> ' + formatTime(stats.avg_response_time_ms);
84
85// Chart defaults
86Chart.defaults.color = colors.squid;
87Chart.defaults.borderColor = colors.squid;
88
89if (stats.recent_activity?.length > 0) {
90 new Chart(document.getElementById("recentActivityChart"), {
91 type: "bar",
92 data: {
93 labels: stats.recent_activity.map((d) => d.day),
94 datasets: [
95 {
96 label: "Sessions",
97 data: stats.recent_activity.map((d) => d.session_count),
98 backgroundColor: colors.charple,
99 borderRadius: 4,
100 yAxisID: "y",
101 },
102 {
103 label: "Tokens (K)",
104 data: stats.recent_activity.map((d) => d.total_tokens / 1000),
105 backgroundColor: colors.julep,
106 borderRadius: 4,
107 yAxisID: "y1",
108 },
109 ],
110 },
111 options: {
112 responsive: true,
113 maintainAspectRatio: false,
114 animation: { duration: 800, easing: easeType },
115 interaction: { mode: "index", intersect: false },
116 scales: {
117 y: { position: "left", title: { display: true, text: "Sessions" } },
118 y1: {
119 position: "right",
120 title: { display: true, text: "Tokens (K)" },
121 grid: { drawOnChartArea: false },
122 },
123 },
124 },
125 });
126}
127
128// Heatmap (Hour × Day of Week) - Bubble Chart
129const dayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
130
131let maxCount =
132 stats.hour_day_heatmap?.length > 0
133 ? Math.max(...stats.hour_day_heatmap.map((h) => h.session_count))
134 : 0;
135if (maxCount === 0) maxCount = 1;
136const scaleFactor = 20 / Math.sqrt(maxCount);
137
138if (stats.hour_day_heatmap?.length > 0) {
139 new Chart(document.getElementById("heatmapChart"), {
140 type: "bubble",
141 data: {
142 datasets: [
143 {
144 label: "Sessions",
145 data: stats.hour_day_heatmap
146 .filter((h) => h.session_count > 0)
147 .map((h) => ({
148 x: h.hour,
149 y: h.day_of_week,
150 r: Math.sqrt(h.session_count) * scaleFactor,
151 count: h.session_count,
152 })),
153 backgroundColor: (ctx) => {
154 const count =
155 ctx.raw?.count || ctx.dataset.data[ctx.dataIndex]?.count || 0;
156 const ratio = count / maxCount;
157 return interpolateColor(ratio);
158 },
159 borderWidth: 0,
160 },
161 ],
162 },
163 options: {
164 responsive: true,
165 maintainAspectRatio: false,
166 animation: false,
167 scales: {
168 x: {
169 min: 0,
170 max: 23,
171 grid: { display: false },
172 title: { display: true, text: "Hour of Day" },
173 ticks: {
174 stepSize: 1,
175 callback: (v) => (Number.isInteger(v) ? v : ""),
176 },
177 },
178 y: {
179 min: 0,
180 max: 6,
181 reverse: true,
182 grid: { display: false },
183 title: { display: true, text: "Day of Week" },
184 ticks: { stepSize: 1, callback: (v) => dayLabels[v] || "" },
185 },
186 },
187 plugins: {
188 legend: { display: false },
189 tooltip: {
190 callbacks: {
191 label: (ctx) =>
192 dayLabels[ctx.raw.y] +
193 " " +
194 ctx.raw.x +
195 ":00 - " +
196 ctx.raw.count +
197 " sessions",
198 },
199 },
200 },
201 },
202 });
203}
204
205if (stats.tool_usage?.length > 0) {
206 const displayTools = getTopItemsWithOthers(
207 stats.tool_usage,
208 "call_count",
209 "tool_name",
210 );
211 const maxValue = Math.max(...displayTools.map((t) => t.call_count));
212 new Chart(document.getElementById("toolChart"), {
213 type: "bar",
214 data: {
215 labels: displayTools.map((t) => t.tool_name),
216 datasets: [
217 {
218 label: "Calls",
219 data: displayTools.map((t) => t.call_count),
220 backgroundColor: (ctx) => {
221 const value = ctx.raw;
222 const ratio = value / maxValue;
223 return interpolateColor(ratio);
224 },
225 borderRadius: 4,
226 },
227 ],
228 },
229 options: {
230 indexAxis: "y",
231 responsive: true,
232 maintainAspectRatio: false,
233 animation: { duration: easeDuration, easing: easeType },
234 plugins: { legend: { display: false } },
235 },
236 });
237}
238
239// Token Distribution Pie
240new Chart(document.getElementById("tokenPieChart"), {
241 type: "doughnut",
242 data: {
243 labels: ["Prompt Tokens", "Completion Tokens"],
244 datasets: [
245 {
246 data: [
247 stats.total.total_prompt_tokens,
248 stats.total.total_completion_tokens,
249 ],
250 backgroundColor: [colors.charple, colors.julep],
251 borderWidth: 0,
252 },
253 ],
254 },
255 options: {
256 responsive: true,
257 maintainAspectRatio: false,
258 animation: { duration: easeDuration, easing: easeType },
259 plugins: {
260 legend: { position: "bottom" },
261 },
262 },
263});
264
265// Model Usage Chart (horizontal bar)
266if (stats.usage_by_model?.length > 0) {
267 const displayModels = getTopItemsWithOthers(
268 stats.usage_by_model,
269 "message_count",
270 "model",
271 );
272 const maxModelValue = Math.max(...displayModels.map((m) => m.message_count));
273 new Chart(document.getElementById("modelChart"), {
274 type: "bar",
275 data: {
276 labels: displayModels.map((m) =>
277 m.provider ? `${m.model} (${m.provider})` : m.model,
278 ),
279 datasets: [
280 {
281 label: "Messages",
282 data: displayModels.map((m) => m.message_count),
283 backgroundColor: (ctx) => {
284 const value = ctx.raw;
285 const ratio = value / maxModelValue;
286 return interpolateColor(ratio);
287 },
288 borderRadius: 4,
289 },
290 ],
291 },
292 options: {
293 indexAxis: "y",
294 responsive: true,
295 maintainAspectRatio: false,
296 animation: { duration: easeDuration, easing: easeType },
297 plugins: { legend: { display: false } },
298 },
299 });
300}
301
302if (stats.usage_by_model?.length > 0) {
303 const providerData = stats.usage_by_model.reduce((acc, m) => {
304 acc[m.provider] = (acc[m.provider] || 0) + m.message_count;
305 return acc;
306 }, {});
307 const providerColors = [
308 colors.malibu,
309 colors.charple,
310 colors.violet,
311 colors.tuna,
312 colors.coral,
313 colors.uni,
314 ];
315 new Chart(document.getElementById("providerPieChart"), {
316 type: "doughnut",
317 data: {
318 labels: Object.keys(providerData),
319 datasets: [
320 {
321 data: Object.values(providerData),
322 backgroundColor: Object.keys(providerData).map(
323 (_, i) => providerColors[i % providerColors.length],
324 ),
325 borderWidth: 0,
326 },
327 ],
328 },
329 options: {
330 responsive: true,
331 maintainAspectRatio: false,
332 animation: { duration: easeDuration, easing: easeType },
333 plugins: {
334 legend: { position: "bottom" },
335 },
336 },
337 });
338}
339
340// Daily Usage Table
341const tableBody = document.querySelector("#daily-table tbody");
342if (stats.usage_by_day?.length > 0) {
343 const fragment = document.createDocumentFragment();
344 stats.usage_by_day.slice(0, 30).forEach((d) => {
345 const row = document.createElement("tr");
346 row.innerHTML = `<td>${d.day}</td><td>${d.session_count}</td><td>${formatNumber(
347 d.prompt_tokens,
348 )}</td><td>${formatNumber(
349 d.completion_tokens,
350 )}</td><td>${formatNumber(d.total_tokens)}</td><td>${formatCost(
351 d.cost,
352 )}</td>`;
353 fragment.appendChild(row);
354 });
355 tableBody.appendChild(fragment);
356}