@chrysb/alphaclaw 0.3.5-beta.1 → 0.4.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/bin/alphaclaw.js +1 -31
  2. package/lib/public/assets/icons/google_icon.svg +8 -0
  3. package/lib/public/css/explorer.css +53 -0
  4. package/lib/public/css/shell.css +21 -19
  5. package/lib/public/css/theme.css +17 -0
  6. package/lib/public/js/app.js +205 -109
  7. package/lib/public/js/components/credentials-modal.js +36 -8
  8. package/lib/public/js/components/file-tree.js +212 -22
  9. package/lib/public/js/components/file-viewer/editor-surface.js +5 -0
  10. package/lib/public/js/components/file-viewer/index.js +47 -6
  11. package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -0
  12. package/lib/public/js/components/file-viewer/status-banners.js +11 -6
  13. package/lib/public/js/components/file-viewer/toolbar.js +56 -1
  14. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +6 -0
  15. package/lib/public/js/components/file-viewer/use-file-diff.js +11 -0
  16. package/lib/public/js/components/file-viewer/use-file-loader.js +12 -2
  17. package/lib/public/js/components/file-viewer/use-file-viewer.js +142 -15
  18. package/lib/public/js/components/google/account-row.js +131 -0
  19. package/lib/public/js/components/google/add-account-modal.js +93 -0
  20. package/lib/public/js/components/google/gmail-setup-wizard.js +450 -0
  21. package/lib/public/js/components/google/gmail-watch-toggle.js +81 -0
  22. package/lib/public/js/components/google/index.js +553 -0
  23. package/lib/public/js/components/google/use-gmail-watch.js +140 -0
  24. package/lib/public/js/components/google/use-google-accounts.js +41 -0
  25. package/lib/public/js/components/icons.js +26 -0
  26. package/lib/public/js/components/scope-picker.js +1 -1
  27. package/lib/public/js/components/sidebar-git-panel.js +48 -20
  28. package/lib/public/js/components/sidebar.js +93 -75
  29. package/lib/public/js/components/toast.js +11 -7
  30. package/lib/public/js/components/usage-tab/constants.js +31 -0
  31. package/lib/public/js/components/usage-tab/formatters.js +24 -0
  32. package/lib/public/js/components/usage-tab/index.js +72 -0
  33. package/lib/public/js/components/usage-tab/overview-section.js +147 -0
  34. package/lib/public/js/components/usage-tab/sessions-section.js +175 -0
  35. package/lib/public/js/components/usage-tab/use-usage-tab.js +241 -0
  36. package/lib/public/js/components/webhooks.js +182 -129
  37. package/lib/public/js/lib/api.js +178 -9
  38. package/lib/public/js/lib/browse-file-policies.js +29 -11
  39. package/lib/public/js/lib/format.js +71 -0
  40. package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
  41. package/lib/public/shared/browse-file-policies.json +13 -0
  42. package/lib/server/constants.js +47 -7
  43. package/lib/server/gmail-push.js +109 -0
  44. package/lib/server/gmail-serve.js +254 -0
  45. package/lib/server/gmail-watch.js +725 -0
  46. package/lib/server/google-state.js +317 -0
  47. package/lib/server/helpers.js +17 -11
  48. package/lib/server/internal-files-migration.js +31 -3
  49. package/lib/server/onboarding/github.js +21 -2
  50. package/lib/server/onboarding/index.js +1 -3
  51. package/lib/server/onboarding/openclaw.js +3 -0
  52. package/lib/server/onboarding/workspace.js +40 -0
  53. package/lib/server/routes/browse/index.js +90 -2
  54. package/lib/server/routes/gmail.js +128 -0
  55. package/lib/server/routes/google.js +433 -213
  56. package/lib/server/routes/system.js +107 -0
  57. package/lib/server/routes/usage.js +29 -2
  58. package/lib/server/routes/webhooks.js +52 -17
  59. package/lib/server/usage-db.js +283 -15
  60. package/lib/server/watchdog.js +66 -0
  61. package/lib/server/webhook-middleware.js +99 -1
  62. package/lib/server/webhooks.js +214 -65
  63. package/lib/server.js +27 -0
  64. package/lib/setup/gitignore +6 -0
  65. package/lib/setup/hourly-git-sync.sh +29 -2
  66. package/package.json +1 -1
  67. package/lib/public/js/components/google.js +0 -228
  68. package/lib/public/js/components/usage-tab.js +0 -531
@@ -0,0 +1,147 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { formatInteger, formatUsd } from "../../lib/format.js";
5
+ import { SegmentedControl } from "../segmented-control.js";
6
+ import { kRangeOptions, kUsageSourceOrder } from "./constants.js";
7
+ import { renderSourceLabel } from "./formatters.js";
8
+
9
+ const html = htm.bind(h);
10
+
11
+ const formatCountLabel = (value, singular, plural) => {
12
+ const count = Number(value || 0);
13
+ const label = count === 1 ? singular : plural;
14
+ return `${formatInteger(count)} ${label}`;
15
+ };
16
+
17
+ const SummaryCard = ({ title, tokens, cost }) => html`
18
+ <div class="bg-surface border border-border rounded-xl p-4">
19
+ <h3 class="card-label text-xs">${title}</h3>
20
+ <div class="text-lg font-semibold mt-1">
21
+ ${formatInteger(tokens)}
22
+ <span class="text-xs text-[var(--text-muted)] ml-1">tokens</span>
23
+ </div>
24
+ <div class="text-xs text-[var(--text-muted)] mt-1">${formatUsd(cost)}</div>
25
+ </div>
26
+ `;
27
+
28
+ const AgentCostDistribution = ({ summary }) => {
29
+ const agents = Array.isArray(summary?.costByAgent?.agents) ? summary.costByAgent.agents : [];
30
+ const [selectedAgent, setSelectedAgent] = useState(() => String(agents[0]?.agent || ""));
31
+ useEffect(() => {
32
+ if (agents.length === 0) {
33
+ if (selectedAgent) setSelectedAgent("");
34
+ return;
35
+ }
36
+ const hasSelectedAgent = agents.some((row) => String(row.agent || "") === selectedAgent);
37
+ if (!hasSelectedAgent) setSelectedAgent(String(agents[0]?.agent || ""));
38
+ }, [agents, selectedAgent]);
39
+ const selectedAgentRow =
40
+ agents.find((row) => String(row.agent || "") === selectedAgent) || agents[0] || null;
41
+
42
+ return html`
43
+ <div class="bg-surface border border-border rounded-xl p-4">
44
+ ${agents.length === 0
45
+ ? html`
46
+ <div class="flex flex-wrap items-start sm:items-center justify-between gap-3 mb-3">
47
+ <h2 class="card-label text-xs">Cost breakdown</h2>
48
+ </div>
49
+ <p class="text-xs text-gray-500">No agent usage recorded for this range.</p>
50
+ `
51
+ : html`
52
+ <div class="space-y-3">
53
+ <div class="flex flex-wrap items-start sm:items-center justify-between gap-3">
54
+ <h2 class="card-label text-xs">Cost breakdown</h2>
55
+ <div class="inline-flex flex-wrap items-center gap-3 text-xs text-gray-500">
56
+ <span class="text-gray-300">${formatUsd(selectedAgentRow?.totalCost)}</span>
57
+ <span>${formatInteger(selectedAgentRow?.totalTokens)} tok</span>
58
+ <label class="inline-flex items-center gap-2 text-xs text-gray-500">
59
+ <select
60
+ class="bg-black/30 border border-border rounded-lg text-xs px-2.5 py-1.5 text-gray-200 focus:border-gray-500"
61
+ value=${String(selectedAgentRow?.agent || "")}
62
+ onChange=${(e) => setSelectedAgent(String(e.currentTarget?.value || ""))}
63
+ >
64
+ ${agents.map(
65
+ (agentRow) => html`
66
+ <option value=${String(agentRow.agent || "")}>
67
+ ${String(agentRow.agent || "unknown")}
68
+ </option>
69
+ `,
70
+ )}
71
+ </select>
72
+ </label>
73
+ </div>
74
+ </div>
75
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
76
+ ${kUsageSourceOrder.map((sourceName) => {
77
+ const sourceRow = (selectedAgentRow?.sourceBreakdown || []).find(
78
+ (row) => String(row.source || "") === sourceName,
79
+ ) || { source: sourceName, totalCost: 0, totalTokens: 0, turnCount: 0 };
80
+ return html`
81
+ <div class="ac-surface-inset px-2.5 py-2">
82
+ <p class="text-[11px] text-gray-500">${renderSourceLabel(sourceRow.source)}</p>
83
+ <p class="text-xs text-gray-300 mt-0.5">${formatUsd(sourceRow.totalCost)}</p>
84
+ <p class="text-[11px] text-gray-500 mt-0.5">
85
+ ${formatInteger(sourceRow.totalTokens)} tok
86
+ ·
87
+ ${formatCountLabel(sourceRow.turnCount, "turn", "turns")}
88
+ </p>
89
+ </div>
90
+ `;
91
+ })}
92
+ </div>
93
+ </div>
94
+ `}
95
+ </div>
96
+ `;
97
+ };
98
+
99
+ export const OverviewSection = ({
100
+ summary = null,
101
+ periodSummary,
102
+ metric = "tokens",
103
+ days = 30,
104
+ overviewCanvasRef,
105
+ onDaysChange = () => {},
106
+ onMetricChange = () => {},
107
+ }) => html`
108
+ <div class="space-y-4">
109
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
110
+ <${SummaryCard} title="Today" tokens=${periodSummary.today.tokens} cost=${periodSummary.today.cost} />
111
+ <${SummaryCard}
112
+ title="Last 7 days"
113
+ tokens=${periodSummary.week.tokens}
114
+ cost=${periodSummary.week.cost}
115
+ />
116
+ <${SummaryCard}
117
+ title="Last 30 days"
118
+ tokens=${periodSummary.month.tokens}
119
+ cost=${periodSummary.month.cost}
120
+ />
121
+ </div>
122
+ <div class="bg-surface border border-border rounded-xl p-4">
123
+ <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
124
+ <h2 class="card-label text-xs">Daily ${metric === "tokens" ? "tokens" : "cost"} by model</h2>
125
+ <div class="flex items-center gap-2">
126
+ <${SegmentedControl}
127
+ options=${kRangeOptions.map((option) => ({ label: option.label, value: option.value }))}
128
+ value=${days}
129
+ onChange=${onDaysChange}
130
+ />
131
+ <${SegmentedControl}
132
+ options=${[
133
+ { label: "tokens", value: "tokens" },
134
+ { label: "cost", value: "cost" },
135
+ ]}
136
+ value=${metric}
137
+ onChange=${onMetricChange}
138
+ />
139
+ </div>
140
+ </div>
141
+ <div style=${{ height: "280px" }}>
142
+ <canvas ref=${overviewCanvasRef}></canvas>
143
+ </div>
144
+ </div>
145
+ <${AgentCostDistribution} summary=${summary} />
146
+ </div>
147
+ `;
@@ -0,0 +1,175 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import {
4
+ formatDurationCompactMs,
5
+ formatInteger,
6
+ formatLocaleDateTimeWithTodayTime,
7
+ formatUsd,
8
+ } from "../../lib/format.js";
9
+ import { kBadgeToneClass } from "./constants.js";
10
+
11
+ const html = htm.bind(h);
12
+
13
+ const formatCountLabel = (value, singular, plural) => {
14
+ const count = Number(value || 0);
15
+ const label = count === 1 ? singular : plural;
16
+ return `${formatInteger(count)} ${label}`;
17
+ };
18
+
19
+ const SessionBadges = ({ session }) => {
20
+ const labels = session?.labels;
21
+ if (!Array.isArray(labels) || labels.length === 0) {
22
+ const fallback = String(session?.sessionKey || session?.sessionId || "");
23
+ return html`<span class="truncate">${fallback}</span>`;
24
+ }
25
+ return html`
26
+ <span class="inline-flex items-center gap-1.5 flex-wrap">
27
+ ${labels.map(
28
+ (badge) => html`
29
+ <span
30
+ class=${`inline-flex items-center px-1.5 py-0.5 rounded border text-[11px] leading-tight ${kBadgeToneClass[badge.tone] || kBadgeToneClass.gray}`}
31
+ >
32
+ ${badge.label}
33
+ </span>
34
+ `,
35
+ )}
36
+ </span>
37
+ `;
38
+ };
39
+
40
+ const SessionInlineDetail = ({
41
+ item,
42
+ expandedSessionIds,
43
+ loadingDetailById,
44
+ sessionDetailById,
45
+ }) => {
46
+ const itemSessionId = String(item.sessionId || "");
47
+ const isExpanded = expandedSessionIds.includes(itemSessionId);
48
+ if (!isExpanded) return null;
49
+ const detail = sessionDetailById[itemSessionId];
50
+ const loadingDetail = !!loadingDetailById[itemSessionId];
51
+ if (loadingDetail) {
52
+ return html`
53
+ <div class="ac-history-body">
54
+ <p class="text-xs text-gray-500">Loading session detail...</p>
55
+ </div>
56
+ `;
57
+ }
58
+ if (!detail) {
59
+ return html`
60
+ <div class="ac-history-body">
61
+ <p class="text-xs text-gray-500">Session detail not available.</p>
62
+ </div>
63
+ `;
64
+ }
65
+ const sessionKeyValue = String(
66
+ detail.sessionKey || item.sessionKey || detail.sessionId || item.sessionId || "",
67
+ ).trim();
68
+ return html`
69
+ <div class="ac-history-body space-y-3 border-0 pt-0 mt-0">
70
+ <div>
71
+ <p class="text-[11px] text-gray-500 mb-1">Session key</p>
72
+ <p class="text-xs text-gray-300 font-mono break-all">${sessionKeyValue || "n/a"}</p>
73
+ </div>
74
+ <div class="mt-1.5">
75
+ <p class="text-[11px] text-gray-500 mb-1">Model breakdown</p>
76
+ ${(detail.modelBreakdown || []).length === 0
77
+ ? html`<p class="text-xs text-gray-500">No model usage recorded.</p>`
78
+ : html`
79
+ <div class="space-y-1.5">
80
+ ${(detail.modelBreakdown || []).map(
81
+ (row) => html`
82
+ <div class="flex items-center justify-between gap-3 text-xs px-1 py-0.5 rounded hover:bg-white/5 transition-colors">
83
+ <span class="text-gray-300 truncate">${row.model || "unknown"}</span>
84
+ <span class="inline-flex items-center gap-3 text-gray-500 shrink-0">
85
+ <span>${formatInteger(row.totalTokens)} tok</span>
86
+ <span>${formatUsd(row.totalCost)}</span>
87
+ <span>${formatCountLabel(row.turnCount, "turn", "turns")}</span>
88
+ </span>
89
+ </div>
90
+ `,
91
+ )}
92
+ </div>
93
+ `}
94
+ </div>
95
+ <div>
96
+ <p class="text-[11px] text-gray-500 mb-1">Tool usage</p>
97
+ ${(detail.toolUsage || []).length === 0
98
+ ? html`<p class="text-xs text-gray-500">No tool calls recorded.</p>`
99
+ : html`
100
+ <div class="space-y-1.5">
101
+ ${(detail.toolUsage || []).map(
102
+ (row) => html`
103
+ <div class="flex items-center justify-between gap-3 text-xs px-1 py-0.5 rounded hover:bg-white/5 transition-colors">
104
+ <span class="text-gray-300 truncate">${row.toolName}</span>
105
+ <span class="inline-flex items-center gap-3 text-gray-500 shrink-0">
106
+ <span>${formatCountLabel(row.callCount, "call", "calls")}</span>
107
+ <span>${(Number(row.errorRate || 0) * 100).toFixed(1)}% err</span>
108
+ <span>${formatDurationCompactMs(row.avgDurationMs)}</span>
109
+ </span>
110
+ </div>
111
+ `,
112
+ )}
113
+ </div>
114
+ `}
115
+ </div>
116
+ </div>
117
+ `;
118
+ };
119
+
120
+ export const SessionsSection = ({
121
+ sessions = [],
122
+ loadingSessions = false,
123
+ expandedSessionIds = [],
124
+ loadingDetailById = {},
125
+ sessionDetailById = {},
126
+ onToggleSession = () => {},
127
+ }) => html`
128
+ <div class="bg-surface border border-border rounded-xl p-4">
129
+ <h2 class="card-label text-xs mb-3">Sessions</h2>
130
+ <div class="ac-history-list">
131
+ ${sessions.length === 0
132
+ ? html`<p class="text-xs text-gray-500">
133
+ ${loadingSessions ? "Loading sessions..." : "No sessions recorded yet."}
134
+ </p>`
135
+ : sessions.map(
136
+ (item) => html`
137
+ <details
138
+ class="ac-history-item"
139
+ open=${expandedSessionIds.includes(String(item.sessionId || ""))}
140
+ ontoggle=${(e) => {
141
+ const itemSessionId = String(item.sessionId || "");
142
+ const isOpen = !!e.currentTarget?.open;
143
+ onToggleSession(itemSessionId, isOpen);
144
+ }}
145
+ >
146
+ <summary class="ac-history-summary hover:bg-white/5 transition-colors">
147
+ <div class="ac-history-summary-row">
148
+ <span class="inline-flex items-center gap-2 min-w-0">
149
+ <span class="ac-history-toggle shrink-0" aria-hidden="true">▸</span>
150
+ <${SessionBadges} session=${item} />
151
+ </span>
152
+ <span class="inline-flex items-center gap-3 shrink-0 text-xs text-gray-500">
153
+ <span>${formatInteger(item.totalTokens)} tok</span>
154
+ <span>${formatUsd(item.totalCost)}</span>
155
+ <span>
156
+ ${formatLocaleDateTimeWithTodayTime(item.lastActivityMs, {
157
+ fallback: "n/a",
158
+ valueIsEpochMs: true,
159
+ })}
160
+ </span>
161
+ </span>
162
+ </div>
163
+ </summary>
164
+ <${SessionInlineDetail}
165
+ item=${item}
166
+ expandedSessionIds=${expandedSessionIds}
167
+ loadingDetailById=${loadingDetailById}
168
+ sessionDetailById=${sessionDetailById}
169
+ />
170
+ </details>
171
+ `,
172
+ )}
173
+ </div>
174
+ </div>
175
+ `;
@@ -0,0 +1,241 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ fetchUsageSessionDetail,
4
+ fetchUsageSessions,
5
+ fetchUsageSummary,
6
+ } from "../../lib/api.js";
7
+ import { formatInteger, formatUsd } from "../../lib/format.js";
8
+ import { readUiSettings, writeUiSettings } from "../../lib/ui-settings.js";
9
+ import {
10
+ kDefaultUsageDays,
11
+ kDefaultUsageMetric,
12
+ kUsageDaysUiSettingKey,
13
+ kUsageMetricUiSettingKey,
14
+ } from "./constants.js";
15
+ import { toChartColor, toLocalDayKey } from "./formatters.js";
16
+
17
+ export const useUsageTab = ({ sessionId = "" }) => {
18
+ const [days, setDays] = useState(() => {
19
+ const settings = readUiSettings();
20
+ const parsedDays = Number.parseInt(String(settings[kUsageDaysUiSettingKey] ?? ""), 10);
21
+ return [7, 30, 90].includes(parsedDays) ? parsedDays : kDefaultUsageDays;
22
+ });
23
+ const [metric, setMetric] = useState(() => {
24
+ const settings = readUiSettings();
25
+ return settings[kUsageMetricUiSettingKey] === "cost" ? "cost" : kDefaultUsageMetric;
26
+ });
27
+ const [summary, setSummary] = useState(null);
28
+ const [sessions, setSessions] = useState([]);
29
+ const [sessionDetailById, setSessionDetailById] = useState({});
30
+ const [loadingSummary, setLoadingSummary] = useState(false);
31
+ const [loadingSessions, setLoadingSessions] = useState(false);
32
+ const [loadingDetailById, setLoadingDetailById] = useState({});
33
+ const [expandedSessionIds, setExpandedSessionIds] = useState(() =>
34
+ sessionId ? [String(sessionId)] : [],
35
+ );
36
+ const [error, setError] = useState("");
37
+ const overviewCanvasRef = useRef(null);
38
+ const overviewChartRef = useRef(null);
39
+
40
+ const loadSummary = useCallback(async () => {
41
+ setLoadingSummary(true);
42
+ setError("");
43
+ try {
44
+ const data = await fetchUsageSummary(days);
45
+ setSummary(data.summary || null);
46
+ } catch (err) {
47
+ setError(err.message || "Could not load usage summary");
48
+ } finally {
49
+ setLoadingSummary(false);
50
+ }
51
+ }, [days]);
52
+
53
+ const loadSessions = useCallback(async () => {
54
+ setLoadingSessions(true);
55
+ try {
56
+ const data = await fetchUsageSessions(100);
57
+ setSessions(Array.isArray(data.sessions) ? data.sessions : []);
58
+ } catch (err) {
59
+ setError(err.message || "Could not load sessions");
60
+ } finally {
61
+ setLoadingSessions(false);
62
+ }
63
+ }, []);
64
+
65
+ const loadSessionDetail = useCallback(async (selectedSessionId) => {
66
+ const safeSessionId = String(selectedSessionId || "").trim();
67
+ if (!safeSessionId) return;
68
+ setLoadingDetailById((currentValue) => ({
69
+ ...currentValue,
70
+ [safeSessionId]: true,
71
+ }));
72
+ try {
73
+ const detailPayload = await fetchUsageSessionDetail(safeSessionId);
74
+ setSessionDetailById((currentValue) => ({
75
+ ...currentValue,
76
+ [safeSessionId]: detailPayload.detail || null,
77
+ }));
78
+ } catch (err) {
79
+ setError(err.message || "Could not load session detail");
80
+ } finally {
81
+ setLoadingDetailById((currentValue) => ({
82
+ ...currentValue,
83
+ [safeSessionId]: false,
84
+ }));
85
+ }
86
+ }, []);
87
+
88
+ useEffect(() => {
89
+ loadSummary();
90
+ }, [loadSummary]);
91
+
92
+ useEffect(() => {
93
+ const settings = readUiSettings();
94
+ settings[kUsageDaysUiSettingKey] = days;
95
+ settings[kUsageMetricUiSettingKey] = metric;
96
+ writeUiSettings(settings);
97
+ }, [days, metric]);
98
+
99
+ useEffect(() => {
100
+ loadSessions();
101
+ }, [loadSessions]);
102
+
103
+ useEffect(() => {
104
+ const safeSessionId = String(sessionId || "").trim();
105
+ if (!safeSessionId) return;
106
+ setExpandedSessionIds((currentValue) =>
107
+ currentValue.includes(safeSessionId) ? currentValue : [...currentValue, safeSessionId],
108
+ );
109
+ if (!sessionDetailById[safeSessionId] && !loadingDetailById[safeSessionId]) {
110
+ loadSessionDetail(safeSessionId);
111
+ }
112
+ }, [sessionId, sessionDetailById, loadingDetailById, loadSessionDetail]);
113
+
114
+ const periodSummary = useMemo(() => {
115
+ const rows = Array.isArray(summary?.daily) ? summary.daily : [];
116
+ const now = new Date();
117
+ const dayKey = toLocalDayKey(now);
118
+ const weekStart = toLocalDayKey(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000));
119
+ const monthStart = toLocalDayKey(new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000));
120
+ const zero = { tokens: 0, cost: 0 };
121
+ return rows.reduce(
122
+ (acc, row) => {
123
+ const tokens = Number(row.totalTokens || 0);
124
+ const cost = Number(row.totalCost || 0);
125
+ if (String(row.date) === dayKey) {
126
+ acc.today.tokens += tokens;
127
+ acc.today.cost += cost;
128
+ }
129
+ if (String(row.date) >= weekStart) {
130
+ acc.week.tokens += tokens;
131
+ acc.week.cost += cost;
132
+ }
133
+ if (String(row.date) >= monthStart) {
134
+ acc.month.tokens += tokens;
135
+ acc.month.cost += cost;
136
+ }
137
+ return acc;
138
+ },
139
+ {
140
+ today: { ...zero },
141
+ week: { ...zero },
142
+ month: { ...zero },
143
+ },
144
+ );
145
+ }, [summary]);
146
+
147
+ const overviewDatasets = useMemo(() => {
148
+ const rows = Array.isArray(summary?.daily) ? summary.daily : [];
149
+ const allModels = new Set();
150
+ for (const dayRow of rows) {
151
+ for (const modelRow of dayRow.models || []) {
152
+ allModels.add(String(modelRow.model || "unknown"));
153
+ }
154
+ }
155
+ const labels = rows.map((row) => String(row.date || ""));
156
+ const datasets = Array.from(allModels).map((model) => ({
157
+ label: model,
158
+ data: rows.map((row) => {
159
+ const found = (row.models || []).find((m) => String(m.model || "") === model);
160
+ if (!found) return 0;
161
+ return metric === "cost" ? Number(found.totalCost || 0) : Number(found.totalTokens || 0);
162
+ }),
163
+ backgroundColor: toChartColor(model),
164
+ }));
165
+ return { labels, datasets };
166
+ }, [summary, metric]);
167
+
168
+ useEffect(() => {
169
+ const canvas = overviewCanvasRef.current;
170
+ const Chart = window.Chart;
171
+ if (!canvas || !Chart) return;
172
+ if (overviewChartRef.current) {
173
+ overviewChartRef.current.destroy();
174
+ overviewChartRef.current = null;
175
+ }
176
+ overviewChartRef.current = new Chart(canvas, {
177
+ type: "bar",
178
+ data: overviewDatasets,
179
+ options: {
180
+ responsive: true,
181
+ maintainAspectRatio: false,
182
+ interaction: { mode: "index", intersect: false },
183
+ scales: {
184
+ x: { stacked: true, ticks: { color: "rgba(156,163,175,1)" } },
185
+ y: {
186
+ stacked: true,
187
+ ticks: {
188
+ color: "rgba(156,163,175,1)",
189
+ callback: (v) => (metric === "cost" ? `$${Number(v).toFixed(2)}` : formatInteger(v)),
190
+ },
191
+ },
192
+ },
193
+ plugins: {
194
+ legend: {
195
+ labels: { color: "rgba(209,213,219,1)", boxWidth: 10, boxHeight: 10 },
196
+ },
197
+ tooltip: {
198
+ callbacks: {
199
+ label: (context) => {
200
+ const value = Number(context.parsed.y || 0);
201
+ return metric === "cost"
202
+ ? `${context.dataset.label}: ${formatUsd(value)}`
203
+ : `${context.dataset.label}: ${formatInteger(value)} tokens`;
204
+ },
205
+ },
206
+ },
207
+ },
208
+ },
209
+ });
210
+ return () => {
211
+ if (overviewChartRef.current) {
212
+ overviewChartRef.current.destroy();
213
+ overviewChartRef.current = null;
214
+ }
215
+ };
216
+ }, [overviewDatasets, metric]);
217
+
218
+ return {
219
+ state: {
220
+ days,
221
+ metric,
222
+ summary,
223
+ sessions,
224
+ sessionDetailById,
225
+ loadingSummary,
226
+ loadingSessions,
227
+ loadingDetailById,
228
+ expandedSessionIds,
229
+ error,
230
+ periodSummary,
231
+ overviewCanvasRef,
232
+ },
233
+ actions: {
234
+ setDays,
235
+ setMetric,
236
+ loadSummary,
237
+ loadSessionDetail,
238
+ setExpandedSessionIds,
239
+ },
240
+ };
241
+ };