index.js

  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}