|
| 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. |
0 commit comments