@codemieai/code 0.2.0 → 0.2.1
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.
- package/README.md +17 -6
- package/dist/agents/plugins/claude/plugin/skills/codemie-analytics/SKILL.md +641 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-analytics/references/leaderboard-dashboard-report.md +225 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-analytics/references/people-spending-dashboard-report.md +746 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-analytics/references/people-spending-dashboard-template.html +3270 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-analytics/scripts/analytics-cli.js +893 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-analytics/scripts/inspect-schema.js +211 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-html-report/README.md +39 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-html-report/SKILL.md +117 -26
- package/dist/agents/plugins/claude/plugin/skills/codemie-html-report/scripts/inject-css.js +40 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-html-report/scripts/inject-data.js +68 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-html-report/style-guide/css/bundle.css +1 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-sdk/SKILL.md +240 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-sdk/examples/assistants.md +256 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-sdk/examples/categories.md +101 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-sdk/examples/datasources.md +401 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-sdk/examples/integrations.md +242 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-sdk/examples/skills.md +191 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-sdk/examples/users.md +38 -0
- package/dist/agents/plugins/claude/plugin/skills/codemie-sdk/examples/workflows.md +151 -0
- package/dist/cli/commands/profile/display.d.ts.map +1 -1
- package/dist/cli/commands/profile/display.js +1 -0
- package/dist/cli/commands/profile/display.js.map +1 -1
- package/dist/cli/commands/proxy/connectors/desktop.d.ts.map +1 -1
- package/dist/cli/commands/proxy/connectors/desktop.js +20 -10
- package/dist/cli/commands/proxy/connectors/desktop.js.map +1 -1
- package/dist/cli/commands/sdk/assistants.d.ts +3 -0
- package/dist/cli/commands/sdk/assistants.d.ts.map +1 -0
- package/dist/cli/commands/sdk/assistants.js +211 -0
- package/dist/cli/commands/sdk/assistants.js.map +1 -0
- package/dist/cli/commands/sdk/categories.d.ts +3 -0
- package/dist/cli/commands/sdk/categories.d.ts.map +1 -0
- package/dist/cli/commands/sdk/categories.js +186 -0
- package/dist/cli/commands/sdk/categories.js.map +1 -0
- package/dist/cli/commands/sdk/datasources.d.ts +3 -0
- package/dist/cli/commands/sdk/datasources.d.ts.map +1 -0
- package/dist/cli/commands/sdk/datasources.js +276 -0
- package/dist/cli/commands/sdk/datasources.js.map +1 -0
- package/dist/cli/commands/sdk/index.d.ts +3 -0
- package/dist/cli/commands/sdk/index.d.ts.map +1 -0
- package/dist/cli/commands/sdk/index.js +23 -0
- package/dist/cli/commands/sdk/index.js.map +1 -0
- package/dist/cli/commands/sdk/integrations.d.ts +3 -0
- package/dist/cli/commands/sdk/integrations.d.ts.map +1 -0
- package/dist/cli/commands/sdk/integrations.js +220 -0
- package/dist/cli/commands/sdk/integrations.js.map +1 -0
- package/dist/cli/commands/sdk/llm.d.ts +3 -0
- package/dist/cli/commands/sdk/llm.d.ts.map +1 -0
- package/dist/cli/commands/sdk/llm.js +48 -0
- package/dist/cli/commands/sdk/llm.js.map +1 -0
- package/dist/cli/commands/sdk/services/assistants.d.ts +13 -0
- package/dist/cli/commands/sdk/services/assistants.d.ts.map +1 -0
- package/dist/cli/commands/sdk/services/assistants.js +60 -0
- package/dist/cli/commands/sdk/services/assistants.js.map +1 -0
- package/dist/cli/commands/sdk/services/categories.d.ts +8 -0
- package/dist/cli/commands/sdk/services/categories.d.ts.map +1 -0
- package/dist/cli/commands/sdk/services/categories.js +19 -0
- package/dist/cli/commands/sdk/services/categories.js.map +1 -0
- package/dist/cli/commands/sdk/services/datasources.d.ts +33 -0
- package/dist/cli/commands/sdk/services/datasources.d.ts.map +1 -0
- package/dist/cli/commands/sdk/services/datasources.js +268 -0
- package/dist/cli/commands/sdk/services/datasources.js.map +1 -0
- package/dist/cli/commands/sdk/services/index.d.ts +6 -0
- package/dist/cli/commands/sdk/services/index.d.ts.map +1 -0
- package/dist/cli/commands/sdk/services/index.js +6 -0
- package/dist/cli/commands/sdk/services/index.js.map +1 -0
- package/dist/cli/commands/sdk/services/integrations.d.ts +27 -0
- package/dist/cli/commands/sdk/services/integrations.d.ts.map +1 -0
- package/dist/cli/commands/sdk/services/integrations.js +59 -0
- package/dist/cli/commands/sdk/services/integrations.js.map +1 -0
- package/dist/cli/commands/sdk/services/llm.d.ts +4 -0
- package/dist/cli/commands/sdk/services/llm.d.ts.map +1 -0
- package/dist/cli/commands/sdk/services/llm.js +7 -0
- package/dist/cli/commands/sdk/services/llm.js.map +1 -0
- package/dist/cli/commands/sdk/services/skills.d.ts +23 -0
- package/dist/cli/commands/sdk/services/skills.d.ts.map +1 -0
- package/dist/cli/commands/sdk/services/skills.js +69 -0
- package/dist/cli/commands/sdk/services/skills.js.map +1 -0
- package/dist/cli/commands/sdk/services/users.d.ts +4 -0
- package/dist/cli/commands/sdk/services/users.d.ts.map +1 -0
- package/dist/cli/commands/sdk/services/users.js +7 -0
- package/dist/cli/commands/sdk/services/users.js.map +1 -0
- package/dist/cli/commands/sdk/services/workflows.d.ts +7 -0
- package/dist/cli/commands/sdk/services/workflows.d.ts.map +1 -0
- package/dist/cli/commands/sdk/services/workflows.js +34 -0
- package/dist/cli/commands/sdk/services/workflows.js.map +1 -0
- package/dist/cli/commands/sdk/skills.d.ts +3 -0
- package/dist/cli/commands/sdk/skills.d.ts.map +1 -0
- package/dist/cli/commands/sdk/skills.js +492 -0
- package/dist/cli/commands/sdk/skills.js.map +1 -0
- package/dist/cli/commands/sdk/users.d.ts +3 -0
- package/dist/cli/commands/sdk/users.d.ts.map +1 -0
- package/dist/cli/commands/sdk/users.js +81 -0
- package/dist/cli/commands/sdk/users.js.map +1 -0
- package/dist/cli/commands/sdk/utils/cli-utils.d.ts +35 -0
- package/dist/cli/commands/sdk/utils/cli-utils.d.ts.map +1 -0
- package/dist/cli/commands/sdk/utils/cli-utils.js +110 -0
- package/dist/cli/commands/sdk/utils/cli-utils.js.map +1 -0
- package/dist/cli/commands/sdk/utils/datasource-types.d.ts +9 -0
- package/dist/cli/commands/sdk/utils/datasource-types.d.ts.map +1 -0
- package/dist/cli/commands/sdk/utils/datasource-types.js +61 -0
- package/dist/cli/commands/sdk/utils/datasource-types.js.map +1 -0
- package/dist/cli/commands/sdk/utils/file-utils.d.ts +8 -0
- package/dist/cli/commands/sdk/utils/file-utils.d.ts.map +1 -0
- package/dist/cli/commands/sdk/utils/file-utils.js +21 -0
- package/dist/cli/commands/sdk/utils/file-utils.js.map +1 -0
- package/dist/cli/commands/sdk/utils/render.d.ts +82 -0
- package/dist/cli/commands/sdk/utils/render.d.ts.map +1 -0
- package/dist/cli/commands/sdk/utils/render.js +149 -0
- package/dist/cli/commands/sdk/utils/render.js.map +1 -0
- package/dist/cli/commands/sdk/workflows.d.ts +3 -0
- package/dist/cli/commands/sdk/workflows.d.ts.map +1 -0
- package/dist/cli/commands/sdk/workflows.js +170 -0
- package/dist/cli/commands/sdk/workflows.js.map +1 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
# People Spending Dashboard — Reference
|
|
2
|
+
|
|
3
|
+
> **Purpose**: This document describes the canonical spending dashboard built for tracking
|
|
4
|
+
> LiteLLM costs for a specific list of people (e.g. a training cohort, project team, or
|
|
5
|
+
> bootcamp). Use it as the authoritative spec whenever someone asks to build a spending
|
|
6
|
+
> report or cost dashboard for a named list of users.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
The people spending dashboard is a **self-contained single-file HTML report** that combines
|
|
13
|
+
direct LiteLLM customer costs with CodeMie platform analytics (leaderboard, CLI insights).
|
|
14
|
+
It is dark-themed, uses the CodeMie design system (inlined CSS), and Chart.js for charts.
|
|
15
|
+
|
|
16
|
+
**Key properties:**
|
|
17
|
+
- No external CSS dependencies — all 8 CodeMie CSS files inlined in `<style>`
|
|
18
|
+
- Google Fonts (`Inter`, `JetBrains Mono`) via `@import`
|
|
19
|
+
- Chart.js 4.4.0 via CDN
|
|
20
|
+
- All data pre-fetched at build time and embedded in JS variables — no runtime API calls
|
|
21
|
+
- Three-account LiteLLM model per user (Web, CLI, Premium) — see account scheme below
|
|
22
|
+
- Fully portable: open in any browser on any machine
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## LiteLLM Account Scheme (3 accounts per user)
|
|
27
|
+
|
|
28
|
+
Each user in the CodeMie platform has up to **three distinct LiteLLM customer accounts**.
|
|
29
|
+
The email address is the base identity:
|
|
30
|
+
|
|
31
|
+
| Account | `end_user_id` pattern | What it tracks |
|
|
32
|
+
|---------|----------------------|----------------|
|
|
33
|
+
| Web / Platform | `first_last@domain.com` | Conversations via the web UI and assistants |
|
|
34
|
+
| CLI | `first_last@domain.com_codemie_cli` | Claude Code / agent CLI sessions |
|
|
35
|
+
| Premium Models | `first_last@domain.com_codemie_premium_models` | Opus / premium model requests |
|
|
36
|
+
|
|
37
|
+
> **Important**: The LiteLLM customer endpoint requires the `end_user_id` query parameter
|
|
38
|
+
> (not `user_id`). The correct endpoint is:
|
|
39
|
+
> ```
|
|
40
|
+
> GET /customer/info?end_user_id=<email_or_email_suffix>
|
|
41
|
+
> ```
|
|
42
|
+
|
|
43
|
+
Each account independently tracks `spend`, `max_budget`, `soft_budget`, and `budget_duration`.
|
|
44
|
+
The total per-user spend is the sum of all three accounts.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Data Sources
|
|
49
|
+
|
|
50
|
+
| Source | How fetched | What it provides |
|
|
51
|
+
|--------|------------|------------------|
|
|
52
|
+
| LiteLLM API | Python `aiohttp` with concurrency (see below) | Actual spend, budget, blocked status per account |
|
|
53
|
+
| CodeMie Leaderboard | `leaderboard --per-page 500` (paginated, all pages) | Tier, champion score, D1–D6 dimensions, rank, usage intent |
|
|
54
|
+
| CodeMie CLI Insights | `cli-insights-users --per-page 500` | CLI sessions, lines added/removed, classification |
|
|
55
|
+
| Source list | Excel/CSV (`.xlsx` via `openpyxl`) | Participant emails |
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Step-by-Step Build Process
|
|
60
|
+
|
|
61
|
+
### Step 1 — Parse the source list
|
|
62
|
+
|
|
63
|
+
The source list is an Excel file with participant emails.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import openpyxl
|
|
67
|
+
|
|
68
|
+
wb = openpyxl.load_workbook('participants.xlsx')
|
|
69
|
+
ws = wb['participants'] # sheet name may vary
|
|
70
|
+
|
|
71
|
+
emails = []
|
|
72
|
+
for row in ws.iter_rows(min_row=3, values_only=True): # row 1 = headers, row 2 = totals
|
|
73
|
+
if row[0] and '@' in str(row[0]):
|
|
74
|
+
emails.append(str(row[0]).strip().lower())
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> **Row layout note**: Row 1 may be column headers, row 2 may be a TOTAL row.
|
|
78
|
+
> Always start from the first data row (check the file structure).
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
### Step 2 — Fetch LiteLLM spend for all 3 accounts per user
|
|
83
|
+
|
|
84
|
+
Use Python `asyncio` + `aiohttp` for concurrent calls. With 656 users × 3 accounts = 1,968
|
|
85
|
+
calls, use a semaphore of 25 to avoid overwhelming the API.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import asyncio, aiohttp, json, os
|
|
89
|
+
|
|
90
|
+
LITELLM_URL = os.environ['LITELLM_URL'] # e.g. https://litellm.my-company.com
|
|
91
|
+
LITELLM_KEY = os.environ['LITELLM_KEY'] # master key or admin key
|
|
92
|
+
|
|
93
|
+
SUFFIXES = {
|
|
94
|
+
'web': '', # plain email
|
|
95
|
+
'cli': '_codemie_cli',
|
|
96
|
+
'premium': '_codemie_premium_models',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async def fetch_customer(session, sem, email, acct_type):
|
|
100
|
+
user_id = email + SUFFIXES[acct_type]
|
|
101
|
+
url = f'{LITELLM_URL}/customer/info'
|
|
102
|
+
headers = {'Authorization': f'Bearer {LITELLM_KEY}'}
|
|
103
|
+
async with sem:
|
|
104
|
+
try:
|
|
105
|
+
async with session.get(url, params={'end_user_id': user_id},
|
|
106
|
+
headers=headers, ssl=False) as resp:
|
|
107
|
+
if resp.status == 200:
|
|
108
|
+
return acct_type, email, await resp.json()
|
|
109
|
+
return acct_type, email, None
|
|
110
|
+
except Exception:
|
|
111
|
+
return acct_type, email, None
|
|
112
|
+
|
|
113
|
+
async def fetch_all(emails):
|
|
114
|
+
sem = asyncio.Semaphore(25)
|
|
115
|
+
connector = aiohttp.TCPConnector(ssl=False)
|
|
116
|
+
results = {}
|
|
117
|
+
async with aiohttp.ClientSession(connector=connector) as session:
|
|
118
|
+
tasks = [
|
|
119
|
+
fetch_customer(session, sem, email, acct)
|
|
120
|
+
for email in emails
|
|
121
|
+
for acct in ('web', 'cli', 'premium')
|
|
122
|
+
]
|
|
123
|
+
for coro in asyncio.as_completed(tasks):
|
|
124
|
+
acct_type, email, data = await coro
|
|
125
|
+
results.setdefault(email, {})[acct_type] = data
|
|
126
|
+
return results
|
|
127
|
+
|
|
128
|
+
raw = asyncio.run(fetch_all(emails))
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**LiteLLM response shape** (one per `end_user_id`):
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"user_id": "first_last@domain.com_codemie_cli",
|
|
136
|
+
"spend": 151.92,
|
|
137
|
+
"max_budget": 150.0,
|
|
138
|
+
"soft_budget": 100.0,
|
|
139
|
+
"budget_duration": "30d",
|
|
140
|
+
"budget_reset_at": "2026-05-01T00:00:00",
|
|
141
|
+
"blocked": false,
|
|
142
|
+
"allowed_model_region": null,
|
|
143
|
+
"allowed_routes": null
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
### Step 3 — Build the enriched users array
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
def extract_budget(acct_data):
|
|
153
|
+
if not acct_data:
|
|
154
|
+
return None
|
|
155
|
+
return {
|
|
156
|
+
'soft': acct_data.get('soft_budget'),
|
|
157
|
+
'max': acct_data.get('max_budget'),
|
|
158
|
+
'duration': acct_data.get('budget_duration'),
|
|
159
|
+
'blocked': acct_data.get('blocked', False),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
users = []
|
|
163
|
+
for email in emails:
|
|
164
|
+
r = raw.get(email, {})
|
|
165
|
+
web_d = r.get('web')
|
|
166
|
+
cli_d = r.get('cli')
|
|
167
|
+
prem_d = r.get('premium')
|
|
168
|
+
|
|
169
|
+
web_spend = float(web_d.get('spend', 0)) if web_d else 0.0
|
|
170
|
+
cli_spend = float(cli_d.get('spend', 0)) if cli_d else 0.0
|
|
171
|
+
prem_spend = float(prem_d.get('spend', 0)) if prem_d else 0.0
|
|
172
|
+
|
|
173
|
+
users.append({
|
|
174
|
+
'email': email,
|
|
175
|
+
'web_spend': round(web_spend, 4),
|
|
176
|
+
'cli_spend': round(cli_spend, 4),
|
|
177
|
+
'premium_spend': round(prem_spend, 4),
|
|
178
|
+
'total_spend': round(web_spend + cli_spend + prem_spend, 4),
|
|
179
|
+
'in_litellm': any([web_d, cli_d, prem_d]),
|
|
180
|
+
'web_budget': extract_budget(web_d),
|
|
181
|
+
'cli_budget': extract_budget(cli_d),
|
|
182
|
+
'premium_budget': extract_budget(prem_d),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
users.sort(key=lambda u: u['total_spend'], reverse=True)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Save to intermediate file** (`/tmp/users_v2.json`) so you can re-run the HTML build
|
|
189
|
+
without re-fetching the API:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
with open('/tmp/users_v2.json', 'w') as f:
|
|
193
|
+
json.dump(users, f)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
### Step 4 — Fetch CodeMie analytics for modal enrichment
|
|
199
|
+
|
|
200
|
+
These calls are optional — they add AI Champion and CLI sections to the per-user modal.
|
|
201
|
+
Run them **after** the LiteLLM fetch to avoid redundant work.
|
|
202
|
+
|
|
203
|
+
#### 4a. Leaderboard (paginated — fetch ALL pages)
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
import subprocess
|
|
207
|
+
|
|
208
|
+
all_lb_rows = []
|
|
209
|
+
CLI = os.path.expanduser('~/.claude/skills/codemie-analytics/scripts/analytics-cli.js')
|
|
210
|
+
|
|
211
|
+
for page in range(1, 100): # break on has_more=false
|
|
212
|
+
result = subprocess.run(
|
|
213
|
+
['node', CLI, 'leaderboard', '--per-page', '500', '--page', str(page), '--output', 'json'],
|
|
214
|
+
capture_output=True, text=True
|
|
215
|
+
)
|
|
216
|
+
data = json.loads(result.stdout)
|
|
217
|
+
all_lb_rows.extend(data['data']['rows'])
|
|
218
|
+
if not data['pagination']['has_more']:
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
lb_by_email = {r['user_email'].lower(): r for r in all_lb_rows if r.get('user_email')}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Leaderboard row shape:**
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{
|
|
228
|
+
"rank": 491,
|
|
229
|
+
"user_email": "sergiy_example@epam.com",
|
|
230
|
+
"user_name": "Sergiy Example",
|
|
231
|
+
"total_score": 35.49,
|
|
232
|
+
"tier_name": "practitioner",
|
|
233
|
+
"d1_score": 49.7,
|
|
234
|
+
"d2_score": 17.5,
|
|
235
|
+
"d3_score": 0,
|
|
236
|
+
"d4_score": 0,
|
|
237
|
+
"d5_score": 63.8,
|
|
238
|
+
"d6_score": 29.2,
|
|
239
|
+
"usage_intent": "developer",
|
|
240
|
+
"usage_intent_label": "💻 Developer"
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
> **Scale note**: The leaderboard has ~12,000+ users. With 500 per page, expect ~25 pages
|
|
245
|
+
> (~30–60 seconds total). Typically 60–65% of a 650-person cohort will appear in the
|
|
246
|
+
> leaderboard (users who haven't engaged at all may not have a record).
|
|
247
|
+
|
|
248
|
+
#### 4b. CLI Insights top spenders
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
result = subprocess.run(
|
|
252
|
+
['node', CLI, 'cli-insights-users', '--time-period', 'last_30_days',
|
|
253
|
+
'--per-page', '500', '--output', 'json'],
|
|
254
|
+
capture_output=True, text=True
|
|
255
|
+
)
|
|
256
|
+
cli_data = json.loads(result.stdout)
|
|
257
|
+
cli_rows = cli_data.get('topBySpend', {}).get('data', {}).get('rows', [])
|
|
258
|
+
cli_by_email = {r['user_email'].lower(): r for r in cli_rows if r.get('user_email')}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**CLI Insights row shape (topBySpend):**
|
|
262
|
+
|
|
263
|
+
```json
|
|
264
|
+
{
|
|
265
|
+
"user_id": "...",
|
|
266
|
+
"user_name": "Danil Melnikov",
|
|
267
|
+
"user_email": "danil_melnikov@epam.com",
|
|
268
|
+
"classification": "production",
|
|
269
|
+
"total_sessions": 134,
|
|
270
|
+
"total_lines_added": 124675,
|
|
271
|
+
"total_lines_removed": 2666,
|
|
272
|
+
"net_lines": 122009,
|
|
273
|
+
"total_cost": 1251.27
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
> **Coverage note**: `topBySpend` returns only the top 500 CLI users globally. For cohorts
|
|
278
|
+
> of 600+ people, only ~3–5% of your list will appear here. This is expected — use it for
|
|
279
|
+
> the "power CLI users" section of the modal.
|
|
280
|
+
|
|
281
|
+
#### 4c. Build the analytics lookup
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
analytics = {}
|
|
285
|
+
for email in [u['email'] for u in users]:
|
|
286
|
+
entry = {}
|
|
287
|
+
|
|
288
|
+
if email in lb_by_email:
|
|
289
|
+
lb = lb_by_email[email]
|
|
290
|
+
entry['lb'] = {
|
|
291
|
+
'rank': lb.get('rank'),
|
|
292
|
+
'tier': lb.get('tier_name'),
|
|
293
|
+
'score': lb.get('total_score'),
|
|
294
|
+
'intent': lb.get('usage_intent_label'),
|
|
295
|
+
'd1': lb.get('d1_score'), 'd2': lb.get('d2_score'),
|
|
296
|
+
'd3': lb.get('d3_score'), 'd4': lb.get('d4_score'),
|
|
297
|
+
'd5': lb.get('d5_score'), 'd6': lb.get('d6_score'),
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if email in cli_by_email:
|
|
301
|
+
c = cli_by_email[email]
|
|
302
|
+
entry['cli'] = {
|
|
303
|
+
'sessions': c.get('total_sessions'),
|
|
304
|
+
'lines_added': c.get('total_lines_added'),
|
|
305
|
+
'lines_removed': c.get('total_lines_removed'),
|
|
306
|
+
'net_lines': c.get('net_lines'),
|
|
307
|
+
'classification': c.get('classification'),
|
|
308
|
+
'cost': c.get('total_cost'),
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if entry:
|
|
312
|
+
analytics[email] = entry
|
|
313
|
+
|
|
314
|
+
with open('/tmp/analytics_enriched.json', 'w') as f:
|
|
315
|
+
json.dump(analytics, f)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
### Step 5 — Compute dashboard KPIs
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
total_users = len(users)
|
|
324
|
+
active_users = sum(1 for u in users if u['total_spend'] > 0)
|
|
325
|
+
in_litellm = sum(1 for u in users if u['in_litellm'])
|
|
326
|
+
web_total = round(sum(u['web_spend'] for u in users), 2)
|
|
327
|
+
cli_total = round(sum(u['cli_spend'] for u in users), 2)
|
|
328
|
+
premium_total = round(sum(u['premium_spend'] for u in users), 2)
|
|
329
|
+
grand_total = round(sum(u['total_spend'] for u in users), 2)
|
|
330
|
+
|
|
331
|
+
active_spends = [u['total_spend'] for u in users if u['total_spend'] > 0]
|
|
332
|
+
avg_active = round(sum(active_spends) / len(active_spends), 2) if active_spends else 0
|
|
333
|
+
|
|
334
|
+
# Budget projection
|
|
335
|
+
OVERHEAD_FACTOR = 1.20 # 20% buffer
|
|
336
|
+
budget_projection = round(avg_active * total_users * OVERHEAD_FACTOR, 2)
|
|
337
|
+
|
|
338
|
+
stats = {
|
|
339
|
+
'total_users': total_users,
|
|
340
|
+
'active_users': active_users,
|
|
341
|
+
'in_litellm': in_litellm,
|
|
342
|
+
'web_total': web_total,
|
|
343
|
+
'cli_total': cli_total,
|
|
344
|
+
'premium_total': premium_total,
|
|
345
|
+
'grand_total': grand_total,
|
|
346
|
+
'avg_active': avg_active,
|
|
347
|
+
'budget_projection': budget_projection,
|
|
348
|
+
'overhead_factor': OVERHEAD_FACTOR,
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Budget projection formula:**
|
|
353
|
+
|
|
354
|
+
```
|
|
355
|
+
Monthly Program Budget = avg_spend_per_active_user × total_program_users × 1.20
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
The 1.20 factor adds a 20% buffer for usage spikes and new users ramping up.
|
|
359
|
+
Use this in a "Budget Projection" widget, not as a hard cost figure.
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
### Step 6 — Compute spend distribution histogram
|
|
364
|
+
|
|
365
|
+
Used for the "Spend Distribution" bar chart:
|
|
366
|
+
|
|
367
|
+
```python
|
|
368
|
+
import math
|
|
369
|
+
|
|
370
|
+
def spend_bucket(s):
|
|
371
|
+
if s == 0: return 'No spend'
|
|
372
|
+
if s < 5: return '$0–$5'
|
|
373
|
+
if s < 20: return '$5–$20'
|
|
374
|
+
if s < 50: return '$20–$50'
|
|
375
|
+
if s < 150: return '$50–$150'
|
|
376
|
+
return '$150+'
|
|
377
|
+
|
|
378
|
+
from collections import Counter
|
|
379
|
+
ranges = Counter(spend_bucket(u['total_spend']) for u in users)
|
|
380
|
+
# Ordered for display:
|
|
381
|
+
ordered_ranges = ['No spend','$0–$5','$5–$20','$20–$50','$50–$150','$150+']
|
|
382
|
+
ranges = {k: ranges.get(k, 0) for k in ordered_ranges}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
### Step 7 — Generate HTML (use the template file + token replacement)
|
|
388
|
+
|
|
389
|
+
**There is a ready-made template HTML file** shipped with this skill:
|
|
390
|
+
[`references/people-spending-dashboard-template.html`](people-spending-dashboard-template.html)
|
|
391
|
+
|
|
392
|
+
It is a complete dark-themed dashboard with inlined CSS, Chart.js, header, 8 stat cards,
|
|
393
|
+
budget projection card, 3 charts, paginated user table, and user detail modal (with
|
|
394
|
+
AI Champion and CLI sections) — the full structure with all data replaced by `__TOKEN__`
|
|
395
|
+
placeholders.
|
|
396
|
+
|
|
397
|
+
**Token reference** (17 placeholders, grouped by purpose):
|
|
398
|
+
|
|
399
|
+
| Token | Type | What to fill in |
|
|
400
|
+
|-------|------|-----------------|
|
|
401
|
+
| `__DASHBOARD_TITLE__` | string (×2 — `<title>` + `<h1>`) | e.g. `Bootcamp Spending Dashboard`, `Team Alpha Spending` |
|
|
402
|
+
| `__TOTAL_USERS__` | int (×4 — subtitle, stat card, base estimate, caption) | Total rows in the source list |
|
|
403
|
+
| `__REPORT_DATE__` | string (×1 — subtitle) | Generation date, e.g. `April 21, 2026` |
|
|
404
|
+
| `__SUBTITLE_NOTES__` | HTML (×1 — subtitle) | Empty `''` by default, or a note like ` · <span style="color:var(--color-warning-text);">Web/Platform: prior-cycle summed in</span>` |
|
|
405
|
+
| `__GRAND_TOTAL__` | string (×1 — stat card) | e.g. `$8,822.48` |
|
|
406
|
+
| `__WEB_TOTAL__` | string (×1 — stat card) | e.g. `$1,731.87` |
|
|
407
|
+
| `__CLI_TOTAL__` | string (×1 — stat card) | e.g. `$7,023.29` |
|
|
408
|
+
| `__PREMIUM_TOTAL__` | string (×1 — stat card) | e.g. `$67.32` |
|
|
409
|
+
| `__IN_LITELLM__` | int (×1 — stat card) | Users with any LiteLLM record |
|
|
410
|
+
| `__ACTIVE_USERS__` | int (×1 — stat card) | Users with `total_spend > 0` |
|
|
411
|
+
| `__AVG_SPEND__` | string (×4 — stat card + projection block) | e.g. `$16.46` — per active user |
|
|
412
|
+
| `__BASE_ESTIMATE__` | string (×1 — projection block) | `$avg × total_users`, e.g. `$10,798` |
|
|
413
|
+
| `__BUFFER_AMOUNT__` | string (×1 — projection block) | +20% of base, e.g. `+$2,160` |
|
|
414
|
+
| `__BUDGET_PROJECTION__` | string (×1 — projection block) | `avg × users × 1.20`, e.g. `$12,957` |
|
|
415
|
+
| `__ALL_USERS_JSON__` | JSON array | Users array (see Step 3) |
|
|
416
|
+
| `__STATS_JSON__` | JSON object | Computed stats (see Step 5) |
|
|
417
|
+
| `__RANGES_JSON__` | JSON object | Spend bucket counts (see Step 6) |
|
|
418
|
+
| `__ANALYTICS_JSON__` | JSON object | Per-email leaderboard + CLI data (see Step 4c) |
|
|
419
|
+
|
|
420
|
+
**When embedding data in HTML with Python, AVOID f-strings for the script block.**
|
|
421
|
+
JavaScript uses `${...}` template literals, which conflict with Python f-string syntax.
|
|
422
|
+
Use plain string concatenation or `str.replace()` with named tokens (as below):
|
|
423
|
+
|
|
424
|
+
```python
|
|
425
|
+
import json, shutil
|
|
426
|
+
|
|
427
|
+
# 1. Copy template to output location
|
|
428
|
+
template_path = '~/.claude/skills/codemie-analytics/references/people-spending-dashboard-template.html'
|
|
429
|
+
output_path = 'reports/my-spending-dashboard.html'
|
|
430
|
+
shutil.copy(os.path.expanduser(template_path), output_path)
|
|
431
|
+
|
|
432
|
+
with open(output_path) as f:
|
|
433
|
+
html = f.read()
|
|
434
|
+
|
|
435
|
+
# 2. JSON blobs
|
|
436
|
+
html = html.replace('__ALL_USERS_JSON__', json.dumps(users, separators=(',', ':')))
|
|
437
|
+
html = html.replace('__STATS_JSON__', json.dumps(stats, separators=(',', ':')))
|
|
438
|
+
html = html.replace('__RANGES_JSON__', json.dumps(ranges, separators=(',', ':')))
|
|
439
|
+
html = html.replace('__ANALYTICS_JSON__', json.dumps(analytics, separators=(',', ':')))
|
|
440
|
+
|
|
441
|
+
# 3. Header / title
|
|
442
|
+
html = html.replace('__DASHBOARD_TITLE__', 'Bootcamp Spending Dashboard')
|
|
443
|
+
html = html.replace('__TOTAL_USERS__', str(stats['total_users']))
|
|
444
|
+
html = html.replace('__REPORT_DATE__', 'April 21, 2026')
|
|
445
|
+
html = html.replace('__SUBTITLE_NOTES__', '') # or methodology note if applicable
|
|
446
|
+
|
|
447
|
+
# 4. Stat card values (currency strings pre-formatted with commas)
|
|
448
|
+
html = html.replace('__GRAND_TOTAL__', f"${stats['grand_total']:,.2f}")
|
|
449
|
+
html = html.replace('__WEB_TOTAL__', f"${stats['web_total']:,.2f}")
|
|
450
|
+
html = html.replace('__CLI_TOTAL__', f"${stats['cli_total']:,.2f}")
|
|
451
|
+
html = html.replace('__PREMIUM_TOTAL__', f"${stats['premium_total']:,.2f}")
|
|
452
|
+
html = html.replace('__IN_LITELLM__', str(stats['in_litellm']))
|
|
453
|
+
html = html.replace('__ACTIVE_USERS__', str(stats['active_users']))
|
|
454
|
+
html = html.replace('__AVG_SPEND__', f"${stats['avg_active']:.2f}")
|
|
455
|
+
|
|
456
|
+
# 5. Budget projection block
|
|
457
|
+
base = round(stats['avg_active'] * stats['total_users'])
|
|
458
|
+
buff = round(base * 0.20)
|
|
459
|
+
html = html.replace('__BASE_ESTIMATE__', f'${base:,}')
|
|
460
|
+
html = html.replace('__BUFFER_AMOUNT__', f'+${buff:,}')
|
|
461
|
+
html = html.replace('__BUDGET_PROJECTION__', f"${stats['budget_projection']:,.0f}")
|
|
462
|
+
|
|
463
|
+
with open(output_path, 'w') as f:
|
|
464
|
+
f.write(html)
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
**Important — leftover tokens check:** after all replacements run,
|
|
468
|
+
`grep -c '__[A-Z_]\+__' output.html` should return **0**. Any remaining `__TOKEN__`
|
|
469
|
+
means a data field was missed.
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
## Page Structure
|
|
474
|
+
|
|
475
|
+
```
|
|
476
|
+
<body class="p-6">
|
|
477
|
+
<div class="container">
|
|
478
|
+
|
|
479
|
+
[Page header]
|
|
480
|
+
h1 "Bootcamp Spending Dashboard"
|
|
481
|
+
p.text-muted "Generated: <date> · Source: LiteLLM"
|
|
482
|
+
|
|
483
|
+
[Top KPI row — 4 stat cards]
|
|
484
|
+
Grand Total Spend (blue)
|
|
485
|
+
Web / Platform (green)
|
|
486
|
+
CLI (purple)
|
|
487
|
+
Premium Models (orange)
|
|
488
|
+
|
|
489
|
+
[Second KPI row — 4 stat cards]
|
|
490
|
+
Participants (total list size)
|
|
491
|
+
In LiteLLM (users found in API)
|
|
492
|
+
Active Spenders (spend > 0)
|
|
493
|
+
Avg Total Spend (per active user)
|
|
494
|
+
|
|
495
|
+
[Budget Projection card — info border]
|
|
496
|
+
Large $XX,XXX/month figure
|
|
497
|
+
Formula: avg_active × total_users × 1.20
|
|
498
|
+
|
|
499
|
+
[Charts row — side by side, align-items: start]
|
|
500
|
+
.card Spend Distribution (vertical bar, height 250px)
|
|
501
|
+
.card Top 10 by Total Spend (horizontal stacked bar, height 460px)
|
|
502
|
+
|
|
503
|
+
[Spend Breakdown by Type — full width bar chart, height 240px]
|
|
504
|
+
Stacked: web + cli + premium per user (top 20)
|
|
505
|
+
|
|
506
|
+
[User Table card]
|
|
507
|
+
Search input + sortable columns
|
|
508
|
+
Columns: User · Web · CLI · Premium · Total · Status
|
|
509
|
+
Each row is clickable → opens detail modal
|
|
510
|
+
Pagination (20 per page)
|
|
511
|
+
|
|
512
|
+
[User Detail Modal]
|
|
513
|
+
(see modal section below)
|
|
514
|
+
|
|
515
|
+
</div>
|
|
516
|
+
</body>
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
## Components Used (CodeMie Design System)
|
|
522
|
+
|
|
523
|
+
| Component | Class(es) | Usage |
|
|
524
|
+
|-----------|-----------|-------|
|
|
525
|
+
| Stat cards | `.stat-grid` / `.stat-card` | KPI tiles at top |
|
|
526
|
+
| Card | `.card` / `.card-header` / `.card-body` | All sections |
|
|
527
|
+
| Table | `.table-wrapper` / `.table` | User list |
|
|
528
|
+
| Badge | `.badge .badge-success/error/warning/...` | Status, tier, classification |
|
|
529
|
+
| Alert | `.alert .alert-info` | Budget projection highlight |
|
|
530
|
+
| Progress bar | `.progress-bar-wrap` / `.progress-bar-fill` | Budget usage bars in modal |
|
|
531
|
+
| DL grid | `.dl-grid` | Key-value detail rows in modal |
|
|
532
|
+
| Pagination | `.pagination` / `.page-btn` | Table pagination |
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
## Charts
|
|
537
|
+
|
|
538
|
+
### 1. Spend Distribution (vertical bar)
|
|
539
|
+
- **Type**: `bar` (vertical, `indexAxis: 'x'`)
|
|
540
|
+
- **Data**: Count of users per spend bucket: `No spend`, `$0–$5`, `$5–$20`, `$20–$50`, `$50–$150`, `$150+`
|
|
541
|
+
- **Colour**: Single colour `#2297F6` (primary blue)
|
|
542
|
+
- **Canvas**: `id="rangeChart"`, `height: 250px`
|
|
543
|
+
|
|
544
|
+
### 2. Top 10 by Total Spend (horizontal stacked bar)
|
|
545
|
+
- **Type**: `bar`, `indexAxis: 'y'`
|
|
546
|
+
- **Data**: Top 10 users' web + cli + premium spend, stacked
|
|
547
|
+
- **Colours**: Web `#259F4C` (green) · CLI `#C084FC` (purple) · Premium `#F5A534` (orange)
|
|
548
|
+
- **Bar thickness**: `barThickness: 28`
|
|
549
|
+
- **Canvas**: `id="topChart"`, `height: 460px`
|
|
550
|
+
- **Key CSS**: The container grid must use `align-items: start` so charts can have independent heights without stretching to match their neighbour.
|
|
551
|
+
|
|
552
|
+
```html
|
|
553
|
+
<div class="charts-row" style="align-items: start;">
|
|
554
|
+
<div class="card">...<canvas id="rangeChart" style="height:250px">...</div>
|
|
555
|
+
<div class="card">...<canvas id="topChart" style="height:460px">...</div>
|
|
556
|
+
</div>
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
### 3. Spend Breakdown by Type (horizontal stacked bar, full width)
|
|
560
|
+
- **Type**: `bar`, `indexAxis: 'y'`
|
|
561
|
+
- **Data**: Top 20 users by total spend, each bar split into web + cli + premium
|
|
562
|
+
- **Canvas**: `id="breakdownChart"`, `height: 240px`
|
|
563
|
+
- **This chart is separate from the Top 10** — it is full-width, below the side-by-side pair.
|
|
564
|
+
|
|
565
|
+
---
|
|
566
|
+
|
|
567
|
+
## User Detail Modal
|
|
568
|
+
|
|
569
|
+
Opens when any table row is clicked. Closes on Escape, close button, or backdrop click.
|
|
570
|
+
|
|
571
|
+
**Layout** (top to bottom):
|
|
572
|
+
|
|
573
|
+
```
|
|
574
|
+
[Total spend] — Large blue number ($XX.XX)
|
|
575
|
+
[Status badge] — "Active" (green) or "No spend" (gray) + "Combined LiteLLM spend" label
|
|
576
|
+
|
|
577
|
+
[Spend breakdown row] — 3-column grid: Web · CLI · Premium (coloured values)
|
|
578
|
+
|
|
579
|
+
[Budget Details] — Per-account budget blocks (Web, CLI, Premium)
|
|
580
|
+
Each block shows:
|
|
581
|
+
Spend · Soft limit · Hard limit · Cycle duration
|
|
582
|
+
Budget used % progress bar (green/orange/red)
|
|
583
|
+
|
|
584
|
+
[AI Champion Profile] — Only if user appears in the leaderboard (~62% of users)
|
|
585
|
+
Tier badge + usage intent label
|
|
586
|
+
Score (large number) / 100 + Rank #N
|
|
587
|
+
D1–D6 dimension bars (coloured horizontal bars, 0–100 scale)
|
|
588
|
+
|
|
589
|
+
[CLI Activity] — Only if user is in top 500 CLI spenders globally
|
|
590
|
+
Classification badge
|
|
591
|
+
Sessions / Lines Added (+green) / Lines Removed (-red) / Net lines
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
**Modal max-width**: `580px`. Set `max-height: 90vh; overflow-y: auto` so long modals scroll.
|
|
595
|
+
|
|
596
|
+
### Tier → badge class mapping
|
|
597
|
+
|
|
598
|
+
| Tier | Badge class | Emoji |
|
|
599
|
+
|------|-------------|-------|
|
|
600
|
+
| pioneer | `badge-advanced` | 🏆 |
|
|
601
|
+
| expert | `badge-in-progress` | ⭐ |
|
|
602
|
+
| advanced | `badge-success` | 🔥 |
|
|
603
|
+
| practitioner | `badge-warning` | 📈 |
|
|
604
|
+
| newcomer | `badge-not-started` | 🌱 |
|
|
605
|
+
|
|
606
|
+
### D1–D6 dimension bar colours
|
|
607
|
+
|
|
608
|
+
| Dimension | Label | Weight | Colour |
|
|
609
|
+
|-----------|-------|--------|--------|
|
|
610
|
+
| D1 | Platform Usage | 20% | `#2297F6` (blue) |
|
|
611
|
+
| D2 | Platform Creation | 20% | `#C084FC` (purple) |
|
|
612
|
+
| D3 | Workflow Usage | 10% | `#259F4C` (green) |
|
|
613
|
+
| D4 | Workflow Creation | 10% | `#F5A534` (orange) |
|
|
614
|
+
| D5 | CLI & Agentic | 30% | `#06B6D4` (cyan) |
|
|
615
|
+
| D6 | Impact & Knowledge | 10% | `#F9303C` (red) |
|
|
616
|
+
|
|
617
|
+
### Table click — use event delegation (NOT onclick attributes)
|
|
618
|
+
|
|
619
|
+
```javascript
|
|
620
|
+
// CORRECT — works for dynamically rendered rows
|
|
621
|
+
document.getElementById('tableBody').addEventListener('click', function(e) {
|
|
622
|
+
const row = e.target.closest('tr[data-email]');
|
|
623
|
+
if (row) openModal(row.getAttribute('data-email'));
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Table row HTML
|
|
627
|
+
`<tr data-email="${encodeURIComponent(user.email)}">...</tr>`
|
|
628
|
+
|
|
629
|
+
// In openModal:
|
|
630
|
+
function openModal(enc) {
|
|
631
|
+
const email = decodeURIComponent(enc);
|
|
632
|
+
const u = ALL_USERS.find(x => x.email === email);
|
|
633
|
+
const an = ANALYTICS_DATA[email] || {};
|
|
634
|
+
...
|
|
635
|
+
}
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
> **Why not onclick=""?** Python string templating mangles single-quote escaping inside
|
|
639
|
+
> HTML attribute strings, producing broken JS like `openModal('' + encodeURIComponent(...))`.
|
|
640
|
+
> `data-*` attributes + event delegation is immune to this quoting problem.
|
|
641
|
+
|
|
642
|
+
---
|
|
643
|
+
|
|
644
|
+
## JavaScript Variables Embedded in HTML
|
|
645
|
+
|
|
646
|
+
| Variable | Type | Source | Size (approx) |
|
|
647
|
+
|----------|------|--------|---------------|
|
|
648
|
+
| `ALL_USERS` | `Array<UserObject>` | LiteLLM `/customer/info` | ~200–400 KB for 600+ users |
|
|
649
|
+
| `STATS` | `Object` | Computed from ALL_USERS | < 1 KB |
|
|
650
|
+
| `RANGES` | `Object` | Spend bucket counts | < 1 KB |
|
|
651
|
+
| `ANALYTICS_DATA` | `Object<email, AnalyticsEntry>` | Leaderboard + CLI insights | ~60–100 KB for 400+ matches |
|
|
652
|
+
|
|
653
|
+
`ANALYTICS_DATA` is keyed by lowercase email. Entries are sparse — only users found in at
|
|
654
|
+
least one analytics source have an entry. Always default to `{}` on lookup:
|
|
655
|
+
|
|
656
|
+
```javascript
|
|
657
|
+
const an = ANALYTICS_DATA[email] || {};
|
|
658
|
+
const lb = an.lb || null; // leaderboard data (may be null)
|
|
659
|
+
const cli = an.cli || null; // CLI insights data (may be null)
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
## Status / Budget Colour Logic
|
|
665
|
+
|
|
666
|
+
```javascript
|
|
667
|
+
function budgetClass(pct) {
|
|
668
|
+
if (pct >= 100) return 'fill-over'; // red
|
|
669
|
+
if (pct >= 75) return 'fill-warn'; // orange
|
|
670
|
+
return 'fill-ok'; // green
|
|
671
|
+
}
|
|
672
|
+
// pct = (spend / soft_budget) * 100, capped at 100
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
Row-level status badge in the table:
|
|
676
|
+
|
|
677
|
+
| Condition | Badge |
|
|
678
|
+
|-----------|-------|
|
|
679
|
+
| `total_spend === 0` | `badge-not-started` — "No spend" |
|
|
680
|
+
| `total_spend > 0` | `badge-success` — "Active" (with dot) |
|
|
681
|
+
|
|
682
|
+
---
|
|
683
|
+
|
|
684
|
+
## Spend Table — Columns and Sorting
|
|
685
|
+
|
|
686
|
+
| Column | Data field | Alignment | Default sort |
|
|
687
|
+
|--------|-----------|-----------|-------------|
|
|
688
|
+
| User (email prefix) | `email` | left | — |
|
|
689
|
+
| Web | `web_spend` | right (`td-number`) | — |
|
|
690
|
+
| CLI | `cli_spend` | right | — |
|
|
691
|
+
| Premium | `premium_spend` | right | — |
|
|
692
|
+
| Total | `total_spend` | right | **desc** (default) |
|
|
693
|
+
| Status | derived from `total_spend` | center | — |
|
|
694
|
+
|
|
695
|
+
Clicking a column header toggles ascending/descending sort. Active column shows
|
|
696
|
+
a caret (`▲`/`▼`). Search filters on email prefix (case-insensitive).
|
|
697
|
+
|
|
698
|
+
Pagination: 20 rows per page. Show "Showing X–Y of Z" counter and page buttons.
|
|
699
|
+
|
|
700
|
+
---
|
|
701
|
+
|
|
702
|
+
## File Location Convention
|
|
703
|
+
|
|
704
|
+
```
|
|
705
|
+
reports/
|
|
706
|
+
├── bootcamp-spending-dashboard.html ← cohort spending dashboard
|
|
707
|
+
├── spending-<team>-<YYYY-MM>.html ← team/project variant
|
|
708
|
+
└── spending-<project>-snapshot.html ← snapshot for a specific project
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
|
|
713
|
+
## Replication Checklist
|
|
714
|
+
|
|
715
|
+
When building a new people spending dashboard:
|
|
716
|
+
|
|
717
|
+
1. **Parse the list** — extract emails from Excel/CSV (check header/total rows).
|
|
718
|
+
2. **Fetch LiteLLM** — use Python asyncio + aiohttp, semaphore(25), `ssl=False`,
|
|
719
|
+
`end_user_id` param. Fetch 3 accounts per user (web, cli, premium suffixes).
|
|
720
|
+
3. **Save raw results** to `/tmp/litellm_raw.json` — avoids re-fetching on HTML rebuild.
|
|
721
|
+
4. **Build users array** — sum the three spend values, extract budget fields.
|
|
722
|
+
5. **Fetch leaderboard** — paginate all pages (`--per-page 500`), build email lookup.
|
|
723
|
+
6. **Fetch CLI insights** — `cli-insights-users --per-page 500`, build email lookup.
|
|
724
|
+
7. **Build analytics dict** — merge leaderboard + CLI into `ANALYTICS_DATA[email]`.
|
|
725
|
+
8. **Compute KPIs** — totals, averages, spend distribution, budget projection (avg × N × 1.20).
|
|
726
|
+
9. **Inline CSS** — read all 8 CodeMie CSS files, concatenate into `<style>`.
|
|
727
|
+
10. **Use token-safe templating** — `str.replace()` with `__TOKEN__` markers, not f-strings.
|
|
728
|
+
11. **Wire table clicks** — `data-email` attribute + event delegation (no onclick= attributes).
|
|
729
|
+
12. **Verify** — open in browser, click rows, confirm modal shows spend + analytics sections.
|
|
730
|
+
13. **Save** to `reports/<descriptive-name>.html`.
|
|
731
|
+
|
|
732
|
+
---
|
|
733
|
+
|
|
734
|
+
## Tips and Gotchas
|
|
735
|
+
|
|
736
|
+
| Issue | Cause | Fix |
|
|
737
|
+
|-------|-------|-----|
|
|
738
|
+
| 422 from LiteLLM | Using `user_id` param | Use `end_user_id` param |
|
|
739
|
+
| JS `onclick` broken | Python quote escaping in template strings | Use `data-email` + event delegation |
|
|
740
|
+
| Top 10 chart labels cut off | CSS Grid `align-items: stretch` makes both cards same height | Add `align-items: start` to `.charts-row` container |
|
|
741
|
+
| `KeyError: 'user_email'` in CLI spend | CLI rows use `user_name` key (not `user_email`) | Use correct key per data source |
|
|
742
|
+
| Only ~60% of users in leaderboard | Users with zero activity have no record | Expected — show analytics section only if `an.lb !== null` |
|
|
743
|
+
| CLI insights coverage only ~3% of cohort | `topBySpend` returns top 500 globally; small cohort ranks low | Expected — only show CLI section when data exists |
|
|
744
|
+
| Large HTML file (300+ KB) | ALL_USERS JSON for 600+ users is large | Acceptable for offline report; no performance issue in browser |
|
|
745
|
+
| Python f-string vs JS template literal conflict | `${...}` in JS conflicts with Python f-string syntax | Use plain string `TEMPLATE` with `__TOKEN__` replace() |
|
|
746
|
+
| Refreshed dashboard shows stale top-card values | Original template hardcoded stat-card values into HTML (not reading from `STATS` JS var) | On a refresh, patch BOTH the JS vars AND the hardcoded HTML. Hardcoded spots to update: `<span class="stat-card-value">$X</span>` (×8 cards), budget projection block (`$12,713` figure + base estimate + "+20% buffer" line + "avg $X × 656 × 1.20" caption). Also the `<h1>` date text. |
|