Skip to content

Commit c39e09c

Browse files
feat: subscriptions management for all 7 agents + fix project name display (#218)
Analytics → History tab now lets you track paid subscriptions and API deposits across all supported services. Service select expanded from 5 to 9 entries (+ API custom): Claude Code, ChatGPT/Codex, Cursor, Copilot, Kiro, OpenCode, Qwen Code, Kilo, API (custom). Picking a service populates the Plan select with current pricing (verified 2026-05-15 against vendor pages); picking a plan auto-fills the monthly amount. API (custom) replaces the Plan select with a free-text provider/balance input. Cost by Project and Most Expensive Sessions now show clean project basenames ("codbash" instead of "~/code/codbash"). Sessions whose path equals \$HOME group into a single "(home)" row. Implementation - Backend helper displayProject() in src/data.js — basename / (home) / unknown — used in byProject aggregation. 14 unit tests via node:test. - SERVICE_PLANS extended; kind field discriminates subscription / api / api-only (Qwen, Kilo). - Native <select> for Service and Plan (replaces datalist); free-text input swapped in for API (custom). - localStorage migration is read-time only, immutable copy; legacy single-entry format gets a "(legacy) " plan prefix. - Form is a <form onsubmit> so Enter submits from any field; updateAddButtonState announces validation reason via aria-describedby. - Subgroups (Subscriptions / API deposits) use <h4> headings. - Touch target on Remove × bumped to 32×32 (WCAG 2.5.8 AA). - Responsive @media flips form to single column under 480px. - Three-agent review (code / security / UX-with-skills): all CRITICAL, HIGH, MEDIUM addressed; selected LOW deferred with reasons in PR body. Co-authored-by: jackrescuer-gif <jackrescuer@gmail.com>
1 parent 654530c commit c39e09c

9 files changed

Lines changed: 1221 additions & 77 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# Analytics — Subscriptions & Project Name Display
2+
3+
> Scope: расширение управления подписками на вкладке Analytics + фикс отображения имени проекта в Cost by Project / Most Expensive Sessions.
4+
5+
## Цель
6+
7+
1. **Subscriptions UI**: пользователь добавляет запись о платной подписке через два связанных выпадающих списка — «Service» → «Plan». При выборе плана поле «Paid ($)» автозаполняется. Поддерживаются все 7 агентов из codbash + опция `API (custom)` для учёта пополнений баланса API.
8+
2. **API deposits**: тот же UI, но при выборе `API (custom)` план превращается в свободный ввод (название провайдера) и сумма вводится вручную. MVP — только пополнения; учёт реального расхода API против баланса — отдельная задача.
9+
3. **Project name fix**: в `Cost by Project` и `Most Expensive Sessions` отображать basename папки (`codbash` вместо `~/code/codbash`). Сессии с `projectPath === $HOME` группировать как `(home)`.
10+
11+
## Инвентаризация данных
12+
13+
| Где | Что |
14+
|-----|-----|
15+
| `src/frontend/app.js:251` | `SERVICE_PLANS` — нужно расширить с 5 до 9 сервисов + API |
16+
| `src/frontend/app.js:276` | `onSubServiceChange` — добавить ветку для `API` (план = свободный input) |
17+
| `src/frontend/app.js:293` | `onSubPlanChange` — автоподстановка цены (без изменений по логике, расширяется через SERVICE_PLANS) |
18+
| `src/frontend/app.js:307` | `getSubscriptionConfig` / `saveSubscriptionConfig` — формат записи нужно расширить (`kind: 'subscription' \| 'api'`) с обратной совместимостью |
19+
| `src/frontend/analytics.js:285` | Cost by Project — рендер `data.byProject`; имя берётся из ключа |
20+
| `src/frontend/analytics.js:309` | Most Expensive Sessions — `s.project` (приходит из API как `project_short`) |
21+
| `src/data.js:4873` | Где формируется `byProject``proj = s.project_short \|\| s.project \|\| 'unknown'` |
22+
| `src/data.js` (~20 мест) | Где выставляется `project_short` через `.replace(os.homedir(), '~')` |
23+
24+
## Карта компонентов
25+
26+
```
27+
Analytics tab
28+
└─ Subscription section (existing)
29+
├─ Service dropdown ← расширяется (9 опций + "API (custom)")
30+
├─ Plan dropdown ← перерисовывается на основе SERVICE_PLANS[service]
31+
├─ Paid ($) input ← autopulls на основе SERVICE_PLANS[service].plans[plan].price
32+
├─ From date
33+
└─ Add → addSubEntry() → localStorage codedash-subscription
34+
35+
Analytics tab
36+
└─ History pane
37+
├─ Cost by Project ← key из byProject (server side)
38+
└─ Most Expensive Sessions ← s.project из топ-сессий
39+
40+
Server (data.js)
41+
└─ build*Analytics() → byProject{} keyed by displayProject()
42+
└─ displayProject(s) helper (NEW) — единая логика basename / (home) / unknown
43+
```
44+
45+
## Модель данных
46+
47+
### LocalStorage `codedash-subscription`
48+
49+
**Текущая версия (с миграцией поддерживается)**:
50+
```js
51+
{ entries: [{ service, plan, paid, from }] }
52+
```
53+
54+
**Новая версия (обратно-совместима)**:
55+
```js
56+
{
57+
entries: [
58+
{
59+
kind: 'subscription' | 'api', // NEW; default 'subscription' для старых записей
60+
service: 'Claude Code', // existing
61+
plan: 'Max 5×', // existing; для kind='api' — произвольная строка ("Anthropic API balance")
62+
paid: 100, // existing
63+
from: '2026-05-01' // existing; для API трактуется как "deposit date"
64+
}
65+
]
66+
}
67+
```
68+
69+
Миграция: запись без `kind` считается `subscription` (read-time fallback в `getSubscriptionConfig`). Существующий код `addSubEntry` пишет с `kind`.
70+
71+
### `SERVICE_PLANS` (расширенный)
72+
73+
```js
74+
// Verified 2026-05-15 against vendor pricing pages (see sources below).
75+
var SERVICE_PLANS = {
76+
'Claude Code': { plans: [
77+
{ name: 'Pro', price: 20 },
78+
{ name: 'Max 5×', price: 100 },
79+
{ name: 'Max 20×', price: 200 }
80+
]},
81+
'ChatGPT/Codex':{ plans: [
82+
{ name: 'Go', price: 8 },
83+
{ name: 'Plus', price: 20 },
84+
{ name: 'Pro', price: 200 }
85+
]},
86+
'Cursor': { plans: [
87+
{ name: 'Pro', price: 20 },
88+
{ name: 'Pro+', price: 60 },
89+
{ name: 'Ultra', price: 200 }
90+
]},
91+
'Copilot': { plans: [
92+
{ name: 'Pro', price: 10 },
93+
{ name: 'Pro+', price: 39 },
94+
{ name: 'Business', price: 19 },
95+
{ name: 'Enterprise', price: 39 }
96+
]},
97+
'Kiro': { plans: [
98+
{ name: 'Pro', price: 20 },
99+
{ name: 'Pro+', price: 40 },
100+
{ name: 'Power', price: 200 }
101+
]},
102+
'OpenCode': { plans: [
103+
{ name: 'Go', price: 10 },
104+
{ name: 'Zen', price: 20 }
105+
]},
106+
'Qwen Code': { plans: [], note: 'free / API-only' },
107+
'Kilo': { plans: [], note: 'free / API-only' },
108+
'API (custom)': { plans: [], note: 'enter provider name and deposit amount' }
109+
};
110+
```
111+
112+
**Sources (verified 2026-05-15)**:
113+
- Claude: `claude.com/pricing` — Pro $20, Max plans starting $100
114+
- ChatGPT: `openai.com/chatgpt/pricing` — Go $8, Plus $20, Pro $200
115+
- Cursor: `cursor.com/pricing` — Pro $20 / Pro+ $60 / Ultra $200
116+
- Copilot: `github.com/features/copilot/plans` + `docs.github.com` — Pro $10, Pro+ $39, Business $19, Enterprise $39
117+
- Kiro: `kiro.dev/pricing` — Pro $20, Pro+ $40, Power $200
118+
- OpenCode: `opencode.ai/go` + `opencode.ai/zen` — Go $10, Zen $20
119+
120+
### `displayProject(session)` — серверный хелпер (новый)
121+
122+
```js
123+
function displayProject(s) {
124+
const raw = s.project || s.project_short || '';
125+
if (!raw || raw === os.homedir() || raw === '~') return '(home)';
126+
// Если уже сокращено до ~/foo/bar — берём последний сегмент
127+
// Если полный путь /Users/x/code/foo — тоже последний сегмент
128+
return path.basename(raw) || 'unknown';
129+
}
130+
```
131+
132+
Используется в `data.js:4873` вместо `s.project_short || s.project || 'unknown'`. Также в формировании `sessionCosts` для `topSessions`.
133+
134+
## API контракт
135+
136+
`/api/analytics/cost` — без изменений по форме, меняется только содержимое:
137+
- ключи `byProject` теперь basenames (`codbash`) или `(home)` или `unknown`
138+
- `topSessions[].project` — тот же displayProject()
139+
140+
## UX & Accessibility
141+
142+
**Целевой WCAG-уровень**: AA.
143+
144+
**Required UI states** (для subscription form):
145+
- [x] **Loading** — N/A, всё локально через localStorage
146+
- [x] **Empty** — если нет записей: показывать `<empty-state>` с пояснением "Add your first subscription to see total monthly spend"
147+
- [x] **Error**`paid <= 0` или `service не выбран` → inline error возле кнопки Add; кнопка disabled пока поля невалидны
148+
- [x] **Success** — после Add: запись появляется в таблице, total пересчитывается, форма очищается
149+
- [x] **Disabled** — кнопка Add disabled когда поля невалидны; Plan disabled пока не выбран Service
150+
- [x] **Partial/Stale** — N/A
151+
- [x] **Optimistic** — N/A (localStorage синхронен)
152+
153+
**Клавиатурный сценарий**:
154+
- Tab order: Service → Plan → Paid → From → Add
155+
- Enter в Paid срабатывает как Add (если форма валидна)
156+
- Visible focus ring на всех контролах (используем existing styles.css :focus-visible)
157+
- На existing удалить-кнопках записи — `aria-label="Remove subscription entry"`
158+
159+
**Screen reader**:
160+
- `<label for="sub-new-service">Service</label>` — для всех инпутов (сейчас часть без label)
161+
- При Add: aria-live polite сообщение "Subscription added: $100 total/month"
162+
- Empty-state — обычный текст в визибл блоке (не aria-hidden)
163+
164+
**Touch targets**: 44×44 (уже соблюдено в existing dropdown стилях, проверить новые).
165+
166+
**Responsive**: dropdown форма уже в flex-wrap; новый длинный список Service не должен ломать mobile — проверить на 375px.
167+
168+
**Performance**: N/A (никаких heavy operations не добавляется).
169+
170+
## Стыки (файлы к изменению)
171+
172+
| Файл | Изменение | Размер |
173+
|------|-----------|--------|
174+
| `src/frontend/app.js` | Расширить SERVICE_PLANS; обновить onSubServiceChange/onSubPlanChange для kind='api'; обновить addSubEntry для kind; миграция в getSubscriptionConfig | ~50 строк |
175+
| `src/frontend/analytics.js` | Subscription-form: добавить labels, aria-live, empty-state. Validation/disabled-state. Опционально — разделение subscriptions/API в выводе. | ~30 строк |
176+
| `src/data.js` | Новый helper `displayProject()` + использование в `byProject` агрегации (~3-4 точки) | ~15 строк |
177+
| `specs/analytics-subscriptions.feature` | BDD сценарии (G3) | новый |
178+
| `tasks/<id>/plan.md` | Implementation plan (G4) | новый |
179+
180+
Никаких новых внешних зависимостей. Backend остаётся zero-deps (codbash CLAUDE.md constraint).
181+
182+
## Риски
183+
184+
| Риск | Mitigation |
185+
|------|-----------|
186+
| Сломать существующие записи в localStorage у пользователей | Read-time миграция: запись без `kind` трактуется как `kind:'subscription'`. Не пишем в storage при чтении. |
187+
| Цены в SERVICE_PLANS устареют | Прокомментировать дату в коде; user всё равно может переписать `paid` руками. Не критично. |
188+
| `path.basename('/Users/x')` → 'x' (имя юзера) на macOS вместо `(home)` | Сравнивать с `os.homedir()` ДО basename. Покрывается тестом. |
189+
| `byProject` ключи теперь могут конфликтовать (`api` папка из work/api и personal/api) | Принято — basename выбран сознательно. Если станет проблемой — switch на parent/basename. Документировано. |
190+
| Длинный список из 9 сервисов на mobile (375px) | Проверить визуально в G6; уже flex-wrap. |
191+
| Пользователь выбрал `Qwen Code` / `Kilo` (нет планов) | План dropdown пустой → форма disabled с подсказкой "This service is free / API-only — use 'API (custom)' instead". |
192+
193+
## Ветка и PR
194+
195+
Новая ветка: `feat/jack-analytics-subscriptions` (соответствует ~/CLAUDE.md namespace для NovakPAai → но в codbash CLAUDE.md использует `feat/` без префикса автора; следую codbash-конвенции → `feat/analytics-subscriptions`).
196+
197+
PR одним коммитом или 2 (feat + fix)? **Предлагаю один PR** — fix для project names тесно связан с UX обновлением Analytics; отдельный fix-PR создаст two-round review.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
Feature: Analytics — Subscriptions management & project name display
2+
As a codbash user
3+
I want to track my paid subscriptions and API deposits across all agents
4+
And see clean project names in cost breakdowns
5+
So that I can understand my actual monthly AI spend
6+
7+
Background:
8+
Given I have opened codbash dashboard
9+
And I am on the "Analytics" tab
10+
And localStorage key "codedash-subscription" is empty
11+
12+
# ── Category 1: Happy path ────────────────────────────────────
13+
14+
Scenario: Add a Claude Max 5× subscription via dropdowns
15+
When I select "Claude Code" from the Service dropdown
16+
Then the Plan dropdown is populated with "Pro", "Max 5×", "Max 20×"
17+
And the Plan dropdown is enabled
18+
When I select "Max 5×" from the Plan dropdown
19+
Then the Paid field auto-fills with "100"
20+
When I enter "2026-05-01" in the From field
21+
And I click "Add"
22+
Then a new subscription row appears: "Claude Code · Max 5× · $100 · 2026-05-01"
23+
And the total monthly spend shows "$100"
24+
And the form is cleared
25+
26+
Scenario: Switching service repopulates plans and resets paid
27+
Given I have selected "Claude Code" and "Max 5×" (paid auto-filled to $100)
28+
When I change the Service to "Cursor"
29+
Then the Plan dropdown is repopulated with "Pro", "Pro+", "Ultra"
30+
And the Paid field is cleared
31+
And the previously selected Plan is no longer shown
32+
33+
Scenario: Add an API deposit (custom)
34+
When I select "API (custom)" from the Service dropdown
35+
Then the Plan field becomes a free-text input with placeholder "Provider / balance label"
36+
And the Paid field is empty and editable
37+
When I type "Anthropic API balance" in the Plan field
38+
And I enter "50" in the Paid field
39+
And I enter "2026-05-10" in the From field
40+
And I click "Add"
41+
Then a new row appears: "API · Anthropic API balance · $50 · 2026-05-10"
42+
And the row is visually grouped under an "API deposits" subtotal
43+
44+
# ── Category 2: Empty state ────────────────────────────────────
45+
46+
Scenario: No subscriptions configured shows empty state
47+
Given localStorage key "codedash-subscription" is empty
48+
When I view the Subscriptions section
49+
Then I see the empty-state message "Add your first subscription to see total monthly spend"
50+
And the empty-state has a visible Add form below it
51+
And the total monthly spend shows "$0"
52+
53+
Scenario: All API deposits removed leaves only subscription subtotal
54+
Given I have one subscription entry "Claude Code · Pro · $20"
55+
And no API deposit entries
56+
When I view the Subscriptions section
57+
Then the "API deposits" subtotal is hidden
58+
And only the "Subscriptions" subtotal is shown
59+
60+
# ── Category 3: Loading state ──────────────────────────────────
61+
62+
Scenario: Slow /api/analytics/cost shows loading state for Cost by Project
63+
Given /api/analytics/cost takes longer than 500ms to respond
64+
When I switch to Analytics tab
65+
Then a loading skeleton is shown for "Cost by Project"
66+
And the Subscriptions section is interactive (does not block on the API)
67+
68+
# N/A: subscription form itself — localStorage is synchronous, no loading state needed
69+
70+
# ── Category 4: Error state ────────────────────────────────────
71+
72+
Scenario: Add button is disabled with no service selected
73+
Given the form is empty
74+
Then the Add button is disabled
75+
And the Plan dropdown is disabled
76+
77+
Scenario: Add button is disabled when paid is zero or negative
78+
Given I have selected "Claude Code" and "Max 5×"
79+
When I clear the Paid field
80+
Then the Add button is disabled
81+
When I enter "-10" in the Paid field
82+
Then the Add button is disabled
83+
And inline validation message reads "Paid amount must be greater than 0"
84+
85+
Scenario: Selecting a free / API-only service shows guidance
86+
When I select "Qwen Code" from the Service dropdown
87+
Then the Plan dropdown is empty and disabled
88+
And a helper text reads "This service is free / API-only — use 'API (custom)' instead"
89+
And the Add button is disabled
90+
91+
Scenario: Corrupted localStorage value falls back to empty entries
92+
Given localStorage key "codedash-subscription" contains invalid JSON
93+
When I view the Subscriptions section
94+
Then I see the empty-state
95+
And no JavaScript error is thrown to the console
96+
97+
# ── Category 5: Keyboard-only navigation ──────────────────────
98+
99+
Scenario: Tab order through Subscriptions form
100+
Given I focus the Service dropdown
101+
When I press Tab
102+
Then focus moves to the Plan dropdown
103+
When I press Tab
104+
Then focus moves to the Paid input
105+
When I press Tab
106+
Then focus moves to the From input
107+
When I press Tab
108+
Then focus moves to the Add button
109+
And every focused element has a visible focus ring
110+
111+
Scenario: Enter in Paid submits the form when valid
112+
Given I have selected "Claude Code", "Max 5×", paid="100"
113+
When I focus the Paid input and press Enter
114+
Then the entry is added (same as clicking Add)
115+
116+
Scenario: Tab through subscription rows reaches the Remove button
117+
Given there are 2 subscription entries
118+
When I tab through the rows
119+
Then each Remove button is focusable
120+
And each Remove button has aria-label="Remove subscription entry"
121+
When I press Enter on a focused Remove button
122+
Then that entry is deleted and aria-live announces "Subscription removed"
123+
124+
# ── Category 6: Edge data ──────────────────────────────────────
125+
126+
Scenario: Cost by Project shows basename, not full path
127+
Given a session exists with projectPath "/Users/pavelnovak/code/codbash"
128+
When I view "Cost by Project"
129+
Then the row label is "codbash"
130+
And the row label is NOT "~/code/codbash"
131+
And the row label does NOT contain "$HOME"
132+
133+
Scenario: Session with projectPath equal to $HOME shows "(home)"
134+
Given a session exists with projectPath equal to os.homedir()
135+
When I view "Cost by Project"
136+
Then the row label is "(home)"
137+
And the row label is NOT "~"
138+
139+
Scenario: Two projects with the same basename merge into one row
140+
Given a session exists with projectPath "/Users/x/work/api"
141+
And a session exists with projectPath "/Users/x/personal/api"
142+
When I view "Cost by Project"
143+
Then there is exactly one row labelled "api"
144+
And the cost is the sum of both sessions
145+
# Accepted collision per SDD decision (basename only)
146+
147+
Scenario: Most Expensive Sessions uses displayProject
148+
Given the most expensive session has projectPath "/Users/x/code/codbash"
149+
When I view "Most Expensive Sessions"
150+
Then the project label shows "codbash"
151+
152+
Scenario: Subscription with very long custom plan name does not break layout
153+
When I add an API entry with plan "Anthropic API balance for billing account ACME-12345-prod"
154+
Then the row text truncates with ellipsis at the table edge
155+
And the full text is available via tooltip / title attribute
156+
157+
Scenario: Subscription with 100+ entries renders without freezing
158+
Given localStorage contains 100 subscription entries
159+
When I open the Analytics tab
160+
Then the Subscriptions section renders within 200ms
161+
And no virtualization is needed (entries are pre-aggregated to a subtotal)
162+
163+
Scenario: Migration from old single-entry format
164+
Given localStorage "codedash-subscription" = {"plan":"Pro","paid":20}
165+
When I open the Analytics tab
166+
Then the entry is shown as "(legacy) · Pro · $20"
167+
And no data is lost
168+
And no error is thrown

0 commit comments

Comments
 (0)