diff --git a/docs/design/analytics-subscriptions.md b/docs/design/analytics-subscriptions.md new file mode 100644 index 0000000..14dab3b --- /dev/null +++ b/docs/design/analytics-subscriptions.md @@ -0,0 +1,197 @@ +# Analytics — Subscriptions & Project Name Display + +> Scope: расширение управления подписками на вкладке Analytics + фикс отображения имени проекта в Cost by Project / Most Expensive Sessions. + +## Цель + +1. **Subscriptions UI**: пользователь добавляет запись о платной подписке через два связанных выпадающих списка — «Service» → «Plan». При выборе плана поле «Paid ($)» автозаполняется. Поддерживаются все 7 агентов из codbash + опция `API (custom)` для учёта пополнений баланса API. +2. **API deposits**: тот же UI, но при выборе `API (custom)` план превращается в свободный ввод (название провайдера) и сумма вводится вручную. MVP — только пополнения; учёт реального расхода API против баланса — отдельная задача. +3. **Project name fix**: в `Cost by Project` и `Most Expensive Sessions` отображать basename папки (`codbash` вместо `~/code/codbash`). Сессии с `projectPath === $HOME` группировать как `(home)`. + +## Инвентаризация данных + +| Где | Что | +|-----|-----| +| `src/frontend/app.js:251` | `SERVICE_PLANS` — нужно расширить с 5 до 9 сервисов + API | +| `src/frontend/app.js:276` | `onSubServiceChange` — добавить ветку для `API` (план = свободный input) | +| `src/frontend/app.js:293` | `onSubPlanChange` — автоподстановка цены (без изменений по логике, расширяется через SERVICE_PLANS) | +| `src/frontend/app.js:307` | `getSubscriptionConfig` / `saveSubscriptionConfig` — формат записи нужно расширить (`kind: 'subscription' \| 'api'`) с обратной совместимостью | +| `src/frontend/analytics.js:285` | Cost by Project — рендер `data.byProject`; имя берётся из ключа | +| `src/frontend/analytics.js:309` | Most Expensive Sessions — `s.project` (приходит из API как `project_short`) | +| `src/data.js:4873` | Где формируется `byProject` — `proj = s.project_short \|\| s.project \|\| 'unknown'` | +| `src/data.js` (~20 мест) | Где выставляется `project_short` через `.replace(os.homedir(), '~')` | + +## Карта компонентов + +``` +Analytics tab +└─ Subscription section (existing) + ├─ Service dropdown ← расширяется (9 опций + "API (custom)") + ├─ Plan dropdown ← перерисовывается на основе SERVICE_PLANS[service] + ├─ Paid ($) input ← autopulls на основе SERVICE_PLANS[service].plans[plan].price + ├─ From date + └─ Add → addSubEntry() → localStorage codedash-subscription + +Analytics tab +└─ History pane + ├─ Cost by Project ← key из byProject (server side) + └─ Most Expensive Sessions ← s.project из топ-сессий + +Server (data.js) +└─ build*Analytics() → byProject{} keyed by displayProject() + └─ displayProject(s) helper (NEW) — единая логика basename / (home) / unknown +``` + +## Модель данных + +### LocalStorage `codedash-subscription` + +**Текущая версия (с миграцией поддерживается)**: +```js +{ entries: [{ service, plan, paid, from }] } +``` + +**Новая версия (обратно-совместима)**: +```js +{ + entries: [ + { + kind: 'subscription' | 'api', // NEW; default 'subscription' для старых записей + service: 'Claude Code', // existing + plan: 'Max 5×', // existing; для kind='api' — произвольная строка ("Anthropic API balance") + paid: 100, // existing + from: '2026-05-01' // existing; для API трактуется как "deposit date" + } + ] +} +``` + +Миграция: запись без `kind` считается `subscription` (read-time fallback в `getSubscriptionConfig`). Существующий код `addSubEntry` пишет с `kind`. + +### `SERVICE_PLANS` (расширенный) + +```js +// Verified 2026-05-15 against vendor pricing pages (see sources below). +var SERVICE_PLANS = { + 'Claude Code': { plans: [ + { name: 'Pro', price: 20 }, + { name: 'Max 5×', price: 100 }, + { name: 'Max 20×', price: 200 } + ]}, + 'ChatGPT/Codex':{ plans: [ + { name: 'Go', price: 8 }, + { name: 'Plus', price: 20 }, + { name: 'Pro', price: 200 } + ]}, + 'Cursor': { plans: [ + { name: 'Pro', price: 20 }, + { name: 'Pro+', price: 60 }, + { name: 'Ultra', price: 200 } + ]}, + 'Copilot': { plans: [ + { name: 'Pro', price: 10 }, + { name: 'Pro+', price: 39 }, + { name: 'Business', price: 19 }, + { name: 'Enterprise', price: 39 } + ]}, + 'Kiro': { plans: [ + { name: 'Pro', price: 20 }, + { name: 'Pro+', price: 40 }, + { name: 'Power', price: 200 } + ]}, + 'OpenCode': { plans: [ + { name: 'Go', price: 10 }, + { name: 'Zen', price: 20 } + ]}, + 'Qwen Code': { plans: [], note: 'free / API-only' }, + 'Kilo': { plans: [], note: 'free / API-only' }, + 'API (custom)': { plans: [], note: 'enter provider name and deposit amount' } +}; +``` + +**Sources (verified 2026-05-15)**: +- Claude: `claude.com/pricing` — Pro $20, Max plans starting $100 +- ChatGPT: `openai.com/chatgpt/pricing` — Go $8, Plus $20, Pro $200 +- Cursor: `cursor.com/pricing` — Pro $20 / Pro+ $60 / Ultra $200 +- Copilot: `github.com/features/copilot/plans` + `docs.github.com` — Pro $10, Pro+ $39, Business $19, Enterprise $39 +- Kiro: `kiro.dev/pricing` — Pro $20, Pro+ $40, Power $200 +- OpenCode: `opencode.ai/go` + `opencode.ai/zen` — Go $10, Zen $20 + +### `displayProject(session)` — серверный хелпер (новый) + +```js +function displayProject(s) { + const raw = s.project || s.project_short || ''; + if (!raw || raw === os.homedir() || raw === '~') return '(home)'; + // Если уже сокращено до ~/foo/bar — берём последний сегмент + // Если полный путь /Users/x/code/foo — тоже последний сегмент + return path.basename(raw) || 'unknown'; +} +``` + +Используется в `data.js:4873` вместо `s.project_short || s.project || 'unknown'`. Также в формировании `sessionCosts` для `topSessions`. + +## API контракт + +`/api/analytics/cost` — без изменений по форме, меняется только содержимое: +- ключи `byProject` теперь basenames (`codbash`) или `(home)` или `unknown` +- `topSessions[].project` — тот же displayProject() + +## UX & Accessibility + +**Целевой WCAG-уровень**: AA. + +**Required UI states** (для subscription form): +- [x] **Loading** — N/A, всё локально через localStorage +- [x] **Empty** — если нет записей: показывать `` с пояснением "Add your first subscription to see total monthly spend" +- [x] **Error** — `paid <= 0` или `service не выбран` → inline error возле кнопки Add; кнопка disabled пока поля невалидны +- [x] **Success** — после Add: запись появляется в таблице, total пересчитывается, форма очищается +- [x] **Disabled** — кнопка Add disabled когда поля невалидны; Plan disabled пока не выбран Service +- [x] **Partial/Stale** — N/A +- [x] **Optimistic** — N/A (localStorage синхронен) + +**Клавиатурный сценарий**: +- Tab order: Service → Plan → Paid → From → Add +- Enter в Paid срабатывает как Add (если форма валидна) +- Visible focus ring на всех контролах (используем existing styles.css :focus-visible) +- На existing удалить-кнопках записи — `aria-label="Remove subscription entry"` + +**Screen reader**: +- `` — для всех инпутов (сейчас часть без label) +- При Add: aria-live polite сообщение "Subscription added: $100 total/month" +- Empty-state — обычный текст в визибл блоке (не aria-hidden) + +**Touch targets**: 44×44 (уже соблюдено в existing dropdown стилях, проверить новые). + +**Responsive**: dropdown форма уже в flex-wrap; новый длинный список Service не должен ломать mobile — проверить на 375px. + +**Performance**: N/A (никаких heavy operations не добавляется). + +## Стыки (файлы к изменению) + +| Файл | Изменение | Размер | +|------|-----------|--------| +| `src/frontend/app.js` | Расширить SERVICE_PLANS; обновить onSubServiceChange/onSubPlanChange для kind='api'; обновить addSubEntry для kind; миграция в getSubscriptionConfig | ~50 строк | +| `src/frontend/analytics.js` | Subscription-form: добавить labels, aria-live, empty-state. Validation/disabled-state. Опционально — разделение subscriptions/API в выводе. | ~30 строк | +| `src/data.js` | Новый helper `displayProject()` + использование в `byProject` агрегации (~3-4 точки) | ~15 строк | +| `specs/analytics-subscriptions.feature` | BDD сценарии (G3) | новый | +| `tasks//plan.md` | Implementation plan (G4) | новый | + +Никаких новых внешних зависимостей. Backend остаётся zero-deps (codbash CLAUDE.md constraint). + +## Риски + +| Риск | Mitigation | +|------|-----------| +| Сломать существующие записи в localStorage у пользователей | Read-time миграция: запись без `kind` трактуется как `kind:'subscription'`. Не пишем в storage при чтении. | +| Цены в SERVICE_PLANS устареют | Прокомментировать дату в коде; user всё равно может переписать `paid` руками. Не критично. | +| `path.basename('/Users/x')` → 'x' (имя юзера) на macOS вместо `(home)` | Сравнивать с `os.homedir()` ДО basename. Покрывается тестом. | +| `byProject` ключи теперь могут конфликтовать (`api` папка из work/api и personal/api) | Принято — basename выбран сознательно. Если станет проблемой — switch на parent/basename. Документировано. | +| Длинный список из 9 сервисов на mobile (375px) | Проверить визуально в G6; уже flex-wrap. | +| Пользователь выбрал `Qwen Code` / `Kilo` (нет планов) | План dropdown пустой → форма disabled с подсказкой "This service is free / API-only — use 'API (custom)' instead". | + +## Ветка и PR + +Новая ветка: `feat/jack-analytics-subscriptions` (соответствует ~/CLAUDE.md namespace для NovakPAai → но в codbash CLAUDE.md использует `feat/` без префикса автора; следую codbash-конвенции → `feat/analytics-subscriptions`). + +PR одним коммитом или 2 (feat + fix)? **Предлагаю один PR** — fix для project names тесно связан с UX обновлением Analytics; отдельный fix-PR создаст two-round review. diff --git a/specs/analytics-subscriptions.feature b/specs/analytics-subscriptions.feature new file mode 100644 index 0000000..8550b27 --- /dev/null +++ b/specs/analytics-subscriptions.feature @@ -0,0 +1,168 @@ +Feature: Analytics — Subscriptions management & project name display + As a codbash user + I want to track my paid subscriptions and API deposits across all agents + And see clean project names in cost breakdowns + So that I can understand my actual monthly AI spend + + Background: + Given I have opened codbash dashboard + And I am on the "Analytics" tab + And localStorage key "codedash-subscription" is empty + + # ── Category 1: Happy path ──────────────────────────────────── + + Scenario: Add a Claude Max 5× subscription via dropdowns + When I select "Claude Code" from the Service dropdown + Then the Plan dropdown is populated with "Pro", "Max 5×", "Max 20×" + And the Plan dropdown is enabled + When I select "Max 5×" from the Plan dropdown + Then the Paid field auto-fills with "100" + When I enter "2026-05-01" in the From field + And I click "Add" + Then a new subscription row appears: "Claude Code · Max 5× · $100 · 2026-05-01" + And the total monthly spend shows "$100" + And the form is cleared + + Scenario: Switching service repopulates plans and resets paid + Given I have selected "Claude Code" and "Max 5×" (paid auto-filled to $100) + When I change the Service to "Cursor" + Then the Plan dropdown is repopulated with "Pro", "Pro+", "Ultra" + And the Paid field is cleared + And the previously selected Plan is no longer shown + + Scenario: Add an API deposit (custom) + When I select "API (custom)" from the Service dropdown + Then the Plan field becomes a free-text input with placeholder "Provider / balance label" + And the Paid field is empty and editable + When I type "Anthropic API balance" in the Plan field + And I enter "50" in the Paid field + And I enter "2026-05-10" in the From field + And I click "Add" + Then a new row appears: "API · Anthropic API balance · $50 · 2026-05-10" + And the row is visually grouped under an "API deposits" subtotal + + # ── Category 2: Empty state ──────────────────────────────────── + + Scenario: No subscriptions configured shows empty state + Given localStorage key "codedash-subscription" is empty + When I view the Subscriptions section + Then I see the empty-state message "Add your first subscription to see total monthly spend" + And the empty-state has a visible Add form below it + And the total monthly spend shows "$0" + + Scenario: All API deposits removed leaves only subscription subtotal + Given I have one subscription entry "Claude Code · Pro · $20" + And no API deposit entries + When I view the Subscriptions section + Then the "API deposits" subtotal is hidden + And only the "Subscriptions" subtotal is shown + + # ── Category 3: Loading state ────────────────────────────────── + + Scenario: Slow /api/analytics/cost shows loading state for Cost by Project + Given /api/analytics/cost takes longer than 500ms to respond + When I switch to Analytics tab + Then a loading skeleton is shown for "Cost by Project" + And the Subscriptions section is interactive (does not block on the API) + + # N/A: subscription form itself — localStorage is synchronous, no loading state needed + + # ── Category 4: Error state ──────────────────────────────────── + + Scenario: Add button is disabled with no service selected + Given the form is empty + Then the Add button is disabled + And the Plan dropdown is disabled + + Scenario: Add button is disabled when paid is zero or negative + Given I have selected "Claude Code" and "Max 5×" + When I clear the Paid field + Then the Add button is disabled + When I enter "-10" in the Paid field + Then the Add button is disabled + And inline validation message reads "Paid amount must be greater than 0" + + Scenario: Selecting a free / API-only service shows guidance + When I select "Qwen Code" from the Service dropdown + Then the Plan dropdown is empty and disabled + And a helper text reads "This service is free / API-only — use 'API (custom)' instead" + And the Add button is disabled + + Scenario: Corrupted localStorage value falls back to empty entries + Given localStorage key "codedash-subscription" contains invalid JSON + When I view the Subscriptions section + Then I see the empty-state + And no JavaScript error is thrown to the console + + # ── Category 5: Keyboard-only navigation ────────────────────── + + Scenario: Tab order through Subscriptions form + Given I focus the Service dropdown + When I press Tab + Then focus moves to the Plan dropdown + When I press Tab + Then focus moves to the Paid input + When I press Tab + Then focus moves to the From input + When I press Tab + Then focus moves to the Add button + And every focused element has a visible focus ring + + Scenario: Enter in Paid submits the form when valid + Given I have selected "Claude Code", "Max 5×", paid="100" + When I focus the Paid input and press Enter + Then the entry is added (same as clicking Add) + + Scenario: Tab through subscription rows reaches the Remove button + Given there are 2 subscription entries + When I tab through the rows + Then each Remove button is focusable + And each Remove button has aria-label="Remove subscription entry" + When I press Enter on a focused Remove button + Then that entry is deleted and aria-live announces "Subscription removed" + + # ── Category 6: Edge data ────────────────────────────────────── + + Scenario: Cost by Project shows basename, not full path + Given a session exists with projectPath "/Users/pavelnovak/code/codbash" + When I view "Cost by Project" + Then the row label is "codbash" + And the row label is NOT "~/code/codbash" + And the row label does NOT contain "$HOME" + + Scenario: Session with projectPath equal to $HOME shows "(home)" + Given a session exists with projectPath equal to os.homedir() + When I view "Cost by Project" + Then the row label is "(home)" + And the row label is NOT "~" + + Scenario: Two projects with the same basename merge into one row + Given a session exists with projectPath "/Users/x/work/api" + And a session exists with projectPath "/Users/x/personal/api" + When I view "Cost by Project" + Then there is exactly one row labelled "api" + And the cost is the sum of both sessions + # Accepted collision per SDD decision (basename only) + + Scenario: Most Expensive Sessions uses displayProject + Given the most expensive session has projectPath "/Users/x/code/codbash" + When I view "Most Expensive Sessions" + Then the project label shows "codbash" + + Scenario: Subscription with very long custom plan name does not break layout + When I add an API entry with plan "Anthropic API balance for billing account ACME-12345-prod" + Then the row text truncates with ellipsis at the table edge + And the full text is available via tooltip / title attribute + + Scenario: Subscription with 100+ entries renders without freezing + Given localStorage contains 100 subscription entries + When I open the Analytics tab + Then the Subscriptions section renders within 200ms + And no virtualization is needed (entries are pre-aggregated to a subtotal) + + Scenario: Migration from old single-entry format + Given localStorage "codedash-subscription" = {"plan":"Pro","paid":20} + When I open the Analytics tab + Then the entry is shown as "(legacy) · Pro · $20" + And no data is lost + And no error is thrown diff --git a/src/data.js b/src/data.js index 5a37033..f84e23e 100644 --- a/src/data.js +++ b/src/data.js @@ -274,6 +274,38 @@ function shortenHomePath(value, homes = ALL_HOMES) { } return value; } + +// Returns a human-readable project label for analytics displays. +// "/Users/x/code/codbash" -> "codbash"; "~/code/foo" -> "foo"; "$HOME" -> "(home)"; empty -> "unknown". +// Same basename from different paths collide intentionally (accepted in SDD). +function displayProject(s, homes = ALL_HOMES) { + if (!s || typeof s !== 'object') return 'unknown'; + let raw = s.project || s.project_short || ''; + if (typeof raw !== 'string') return 'unknown'; + raw = raw.trim(); + if (!raw) return 'unknown'; + if (raw === '~') return '(home)'; + const homeList = Array.isArray(homes) && homes.length ? homes : [os.homedir()]; + if (raw.startsWith('~/') || raw.startsWith('~\\')) { + const tail = raw.slice(2); + if (!tail) return '(home)'; + raw = path.join(homeList[0], tail); + } + const normalized = normalizeProjectPath(raw); + for (const homeRaw of homeList) { + const home = normalizeProjectPath(homeRaw); + if (home && normalized.toLowerCase() === home.toLowerCase()) return '(home)'; + } + // Strip trailing slash before basename to avoid empty result on "/foo/bar/" + const trimmed = normalized.replace(/[\\/]+$/, ''); + // Handle Windows-style paths even on POSIX (path.basename treats them as one segment) + const lastWin = trimmed.lastIndexOf('\\'); + const lastUnix = trimmed.lastIndexOf('/'); + const lastSep = Math.max(lastWin, lastUnix); + const base = lastSep >= 0 ? trimmed.slice(lastSep + 1) : trimmed; + return base || 'unknown'; +} + // OpenCode built-in tools that should NOT be treated as MCP servers const OPENCODE_BUILTIN_TOOLS = new Set([ 'read', 'write', 'edit', 'bash', 'glob', 'grep', 'task', 'todowrite', @@ -4871,7 +4903,7 @@ function _computeCostAnalytics(sessions) { } // By project - const proj = s.project_short || s.project || 'unknown'; + const proj = displayProject(s); if (!byProject[proj]) byProject[proj] = { cost: 0, sessions: 0, tokens: 0 }; byProject[proj].cost += cost; byProject[proj].sessions++; @@ -5531,6 +5563,7 @@ module.exports = { buildWslUncPath, normalizeProjectPath, shortenHomePath, + displayProject, detectWindowsWslHomes, parseStructuredWrapper, parseStructuredFields, diff --git a/src/frontend/analytics.js b/src/frontend/analytics.js index 7763ac4..fa4e6b4 100644 --- a/src/frontend/analytics.js +++ b/src/frontend/analytics.js @@ -207,15 +207,24 @@ async function renderAnalytics(container) { // ── Subscription vs API ──────────────────────────────────── var sub = getSubscriptionConfig(); var subEntries = (sub && sub.entries) || []; - var totalPaid = subTotalPaid(subEntries); + // Annotate every entry with its original index so per-row Remove buttons + // map back into the combined array after splitting by kind. + var subIndexed = subEntries.map(function(e, i) { return { entry: e, idx: i }; }); + var subOnly = subIndexed.filter(function(x){ return (x.entry.kind || 'subscription') === 'subscription'; }); + var apiOnly = subIndexed.filter(function(x){ return x.entry.kind === 'api'; }); + var totalSubs = subTotalPaid(subOnly.map(function(x){return x.entry;})); + var totalApi = subTotalPaid(apiOnly.map(function(x){return x.entry;})); + var totalPaid = totalSubs; // ROI vs API rates is meaningful only for subscriptions html += '
'; html += '

Subscription vs API

'; + html += '
'; if (totalPaid > 0) { var savings = data.totalCost - totalPaid; var multiplier = data.totalCost / totalPaid; var savingsPositive = savings > 0; - var breakdown = subEntries.map(function(e) { + var breakdown = subOnly.map(function(x) { + var e = x.entry; var prefix = e.service ? escHtml(e.service) + ' ' : ''; return prefix + escHtml(e.plan || 'Sub') + ' $' + parseFloat(e.paid).toFixed(0); }).join(' + '); @@ -228,39 +237,61 @@ async function renderAnalytics(container) { html += '
'; html += '
'; html += '
'; + } else if (subEntries.length === 0) { + html += '

Add your first subscription to see total monthly spend.

'; } else { html += '

Add your subscription periods below to see how much you\'re saving vs API rates.

'; } - // Period list + // Entries grouped by kind. (Function expression \u2014 block-scoped declarations + // inside try{} have implementation-defined semantics.) html += '
'; - if (subEntries.length > 0) { - subEntries.forEach(function(e, i) { - var serviceLabel = e.service && SERVICE_PLANS[e.service] ? SERVICE_PLANS[e.service].label : e.service || ''; - html += '
'; - if (serviceLabel) html += '' + escHtml(serviceLabel) + ''; - html += '' + escHtml(e.plan || '\u2014') + ''; - html += '$' + parseFloat(e.paid || 0).toFixed(2) + '/mo'; - html += '' + (e.from ? 'from ' + escHtml(e.from) : 'no date') + ''; - html += ''; - html += '
'; - }); + var renderEntryRow = function(x) { + var e = x.entry; + var serviceLabel = e.service && SERVICE_PLANS[e.service] ? SERVICE_PLANS[e.service].label : e.service || ''; + var paidSuffix = e.kind === 'api' ? '' : '/mo'; + var fromText = e.from ? 'from ' + escHtml(e.from) : 'no date'; + // x.idx is a non-negative integer from Array#map \u2014 safe to inline. + var rowHtml = '
'; + if (serviceLabel) rowHtml += '' + escHtml(serviceLabel) + ''; + rowHtml += '' + escHtml(e.plan || '\u2014') + ''; + rowHtml += '$' + parseFloat(e.paid || 0).toFixed(2) + paidSuffix + ''; + rowHtml += '' + fromText + ''; + rowHtml += ''; + rowHtml += '
'; + return rowHtml; + }; + if (subOnly.length > 0) { + html += '

Subscriptions \u2014 $' + totalSubs.toFixed(2) + ' / month

'; + subOnly.forEach(function(x){ html += renderEntryRow(x); }); + } + if (apiOnly.length > 0) { + html += '

API deposits \u2014 $' + totalApi.toFixed(2) + ' total

'; + apiOnly.forEach(function(x){ html += renderEntryRow(x); }); } html += '
'; - // Add form - var serviceDatalistOpts = Object.keys(SERVICE_PLANS).map(function(k) { - return '' + + Object.keys(SERVICE_PLANS).map(function(k) { + var cfg = SERVICE_PLANS[k]; + var label = cfg && cfg.label ? cfg.label : k; + return ''; + }).join(''); + html += '
'; + html += ''; + html += ''; + // Plan slot — replaced dynamically by onSubServiceChange (select for plans / input for API custom) + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
'; html += '
'; // ── Daily cost chart ─────────────────────────────────────── diff --git a/src/frontend/app.js b/src/frontend/app.js index cad7be5..584ba00 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -604,93 +604,214 @@ function getEstimatedSessionCost(session) { return estimateCost(session.file_size); } -// ── Subscription service plans (pricing as of 2025) ───────────── +// ── Subscription service plans ───────────────────────────────── +// Pricing verified 2026-05-15 against vendor pages. +// Sources: claude.com/pricing, openai.com/chatgpt/pricing, cursor.com/pricing, +// github.com/features/copilot/plans + docs.github.com, kiro.dev/pricing, +// opencode.ai/go + opencode.ai/zen var SERVICE_PLANS = { - 'Claude': { label: 'Claude (Anthropic)', plans: [ + 'Claude Code': { label: 'Claude Code (Anthropic)', kind: 'subscription', plans: [ { name: 'Pro', price: 20 }, { name: 'Max 5×', price: 100 }, { name: 'Max 20×', price: 200 } ]}, - 'OpenAI': { label: 'OpenAI (ChatGPT)', plans: [ + 'ChatGPT/Codex': { label: 'ChatGPT / Codex (OpenAI)', kind: 'subscription', plans: [ + { name: 'Go', price: 8 }, { name: 'Plus', price: 20 }, { name: 'Pro', price: 200 } ]}, - 'Cursor': { label: 'Cursor', plans: [ + 'Cursor': { label: 'Cursor', kind: 'subscription', plans: [ { name: 'Pro', price: 20 }, { name: 'Pro+', price: 60 }, { name: 'Ultra', price: 200 } ]}, - 'Kiro': { label: 'Kiro', plans: [ + 'Copilot': { label: 'GitHub Copilot', kind: 'subscription', plans: [ + { name: 'Pro', price: 10 }, + { name: 'Pro+', price: 39 }, + { name: 'Business', price: 19 }, + { name: 'Enterprise', price: 39 } + ]}, + 'Kiro': { label: 'Kiro', kind: 'subscription', plans: [ { name: 'Pro', price: 20 }, { name: 'Pro+', price: 40 }, { name: 'Power', price: 200 } ]}, - 'OpenCode': { label: 'OpenCode', plans: [ - { name: 'Go', price: 10 } - ]} + 'OpenCode': { label: 'OpenCode', kind: 'subscription', plans: [ + { name: 'Go', price: 10 }, + { name: 'Zen', price: 20 } + ]}, + 'Qwen Code': { label: 'Qwen Code', kind: 'api-only', plans: [], + note: 'Free / API-only — use "API (custom)" to track deposits' }, + 'Kilo': { label: 'Kilo', kind: 'api-only', plans: [], + note: 'Free / API-only — use "API (custom)" to track deposits' }, + 'API (custom)': { label: 'API (custom)', kind: 'api', plans: [], + note: 'Enter provider/balance label and deposit amount manually' } }; +// Rebuild the Plan slot in-place: for API (custom). +// Service+plan values come from SERVICE_PLANS constants, but escape on principle (defence in depth). +function renderPlanSlot(cfg) { + var slot = document.getElementById('sub-plan-slot'); + if (!slot) return; + if (cfg && cfg.kind === 'api') { + slot.innerHTML = + '' + + ''; + } else if (cfg && cfg.plans && cfg.plans.length > 0) { + var opts = '' + cfg.plans.map(function(p) { + var nm = escHtml(String(p.name)); + var pr = escHtml(String(parseFloat(p.price) || 0)); + return ''; + }).join(''); + slot.innerHTML = + '' + + ''; + } else { + // No service selected, or api-only with no plans → disabled placeholder select + slot.innerHTML = + '' + + ''; + } +} + function onSubServiceChange() { var serviceEl = document.getElementById('sub-new-service'); - var planOpts = document.getElementById('sub-plan-opts'); - var service = serviceEl ? serviceEl.value.trim() : ''; - if (!planOpts) return; - planOpts.innerHTML = ''; var paidEl = document.getElementById('sub-new-paid'); - if (paidEl) paidEl.value = ''; - if (service && SERVICE_PLANS[service]) { - SERVICE_PLANS[service].plans.forEach(function(p) { - var opt = document.createElement('option'); - opt.value = p.name; - planOpts.appendChild(opt); - }); + var service = serviceEl ? serviceEl.value.trim() : ''; + var cfg = SERVICE_PLANS[service]; + if (paidEl) { + paidEl.value = ''; + paidEl.placeholder = cfg && cfg.kind === 'api' ? '$ deposit' : '$/mo'; } + renderPlanSlot(cfg); + // hint text is fully driven by updateAddButtonState (priority: cfg.note > validation reason) + updateAddButtonState(); } function onSubPlanChange() { + // Plan '; +html += ''; +html += ''; +html += ''; +html += ''; +html += ''; +html += ''; +html += '
'; +html += ''; +``` + +### 5.4 CSS additions + +**File**: `src/frontend/styles.css` + +```css +.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } +.sub-group-header { font-size:11px; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.5px; margin-top:8px; padding-bottom:4px; border-bottom:1px solid var(--border); } +.sub-empty { color:var(--text-secondary); font-style:italic; padding:8px 0; } +.sub-hint-line { grid-column:1/-1; font-size:11px; color:var(--text-secondary); min-height:14px; } +.sub-entry-plan { max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +#sub-add-btn:disabled { opacity:0.4; cursor:not-allowed; } +``` + +## Phase 6 — Tests + +**File**: `test/displayProject.test.js` (new — node:test, zero-dep) + +```js +const test = require('node:test'); +const assert = require('node:assert'); +const os = require('os'); +const { displayProject } = require('../src/data.js'); + +test('basename for full path', () => { + assert.strictEqual(displayProject({ project: '/Users/x/code/codbash' }), 'codbash'); +}); +test('basename for tilde path', () => { + assert.strictEqual(displayProject({ project: '~/code/codbash' }), 'codbash'); +}); +test('(home) for homedir', () => { + assert.strictEqual(displayProject({ project: os.homedir() }), '(home)'); +}); +test('(home) for bare tilde', () => { + assert.strictEqual(displayProject({ project: '~' }), '(home)'); +}); +test('unknown for empty', () => { + assert.strictEqual(displayProject({}), 'unknown'); +}); +test('falls back to project_short', () => { + assert.strictEqual(displayProject({ project_short: '~/code/foo' }), 'foo'); +}); +test('basename collision is accepted (same name → same key)', () => { + assert.strictEqual(displayProject({ project: '/a/b/api' }), 'api'); + assert.strictEqual(displayProject({ project: '/c/d/api' }), 'api'); +}); +``` + +Run: `node --test test/displayProject.test.js` + +For frontend behaviour (`onSubServiceChange`, `addSubEntry`, migration) — manual smoke via dev server (no Playwright in this repo per CLAUDE.md zero-deps constraint), checklist in smoke-report.md. + +## Phase 7 — Smoke (feature-smoke skill) + +Per `~/.claude/skills/feature-smoke/SKILL.md`: +1. Boot codbash on ephemeral port (`PORT=0 codbash run --no-open`) +2. curl `/` and verify HTML renders +3. curl `/api/analytics/cost` and verify `byProject` keys do not contain `~/` or `$HOME` +4. UI handoff to `e2e-runner` for: select Claude Code → verify plan dropdown populates; select Max 5× → verify paid=100; select API → verify plan placeholder changes; add 2 entries → verify both render; corrupt localStorage → verify no console error; tab through form → verify focus visible +5. Write `tasks/2026-05-15-analytics-subscriptions/smoke-report.md` with verdict. + +## Risks specific to implementation + +| Risk | Mitigation | Status field | +|------|-----------|--------------| +| `path.basename('/Users/x')` returns `'x'` not `(home)` | Compare to `os.homedir()` BEFORE basename | addressed_in: `src/data.js displayProject` + test `(home) for homedir` | +| Datalist `` allows free text not in list → user types "Max 5x" (ASCII x, not ×) → no price autofill | Match case-insensitive AND normalize × ↔ x in `onSubPlanChange` | addressed_in: `onSubPlanChange` (existing toLowerCase already there; add `.replace(/x/g, '×')` symmetric match) | +| Old localStorage entry without `kind` field after Phase 4.5 migration is in-memory only — if user removes one entry, save persists migrated form → fine, but if user has 2 tabs open and one tab pre-migration writes back, kind is lost | Tab-collision rare; on next read we re-migrate. Accept. | accepted_because: rare edge case, no data loss possible | +| `removeSubEntry(i)` uses combined-array index; after splitting render into 2 groups, group-loop index ≠ combined index | Pass original index from filter loop, not enumeration of filtered array | addressed_in: Phase 5.2 (explicit comment in code) | +| Datalist plan dropdown does not "open like a select" on click — only on typing in some browsers | Acceptable UX trade-off; documented; if user complains we switch to `` | accepted_because: matches existing form pattern | diff --git a/test/display-project.test.js b/test/display-project.test.js new file mode 100644 index 0000000..3e5575b --- /dev/null +++ b/test/display-project.test.js @@ -0,0 +1,77 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const os = require('os'); +const path = require('path'); + +const data = require('../src/data'); +const displayProject = data.__test && data.__test.displayProject; + +test('displayProject is exported via __test', () => { + assert.equal(typeof displayProject, 'function', + 'displayProject must be exported from src/data.js via __test'); +}); + +test('basename for absolute path', () => { + assert.equal(displayProject({ project: '/Users/x/code/codbash' }), 'codbash'); +}); + +test('basename for tilde path', () => { + assert.equal(displayProject({ project: '~/code/codbash' }), 'codbash'); +}); + +test('basename for nested tilde path', () => { + assert.equal(displayProject({ project: '~/work/api' }), 'api'); +}); + +test('(home) for absolute homedir path', () => { + assert.equal(displayProject({ project: os.homedir() }), '(home)'); +}); + +test('(home) for bare tilde', () => { + assert.equal(displayProject({ project: '~' }), '(home)'); +}); + +test('falls back to project_short when project missing', () => { + assert.equal(displayProject({ project_short: '~/code/foo' }), 'foo'); +}); + +test('prefers project over project_short when both present', () => { + assert.equal( + displayProject({ project: '/Users/x/code/codbash', project_short: '~/old/path' }), + 'codbash' + ); +}); + +test('returns "unknown" for empty input', () => { + assert.equal(displayProject({}), 'unknown'); + assert.equal(displayProject({ project: '', project_short: '' }), 'unknown'); + assert.equal(displayProject({ project: null }), 'unknown'); +}); + +test('returns "unknown" for null/undefined session', () => { + assert.equal(displayProject(null), 'unknown'); + assert.equal(displayProject(undefined), 'unknown'); +}); + +test('basename collision: two paths with same last segment merge to same key', () => { + // Accepted collision per SDD decision (basename-only display) + assert.equal(displayProject({ project: '/a/b/api' }), 'api'); + assert.equal(displayProject({ project: '/c/d/api' }), 'api'); +}); + +test('trailing slash does not produce empty basename', () => { + // path.basename('/Users/x/code/codbash/') === 'codbash' in node + assert.equal(displayProject({ project: '/Users/x/code/codbash/' }), 'codbash'); +}); + +test('Windows-style path basename', () => { + // Windows paths may appear in WSL/cross-platform data + const result = displayProject({ project: 'C:\\Users\\x\\code\\myproj' }); + // Either basename('myproj') or treated as full string — we want a sane name, not the full path + assert.ok(result === 'myproj' || result.length < 'C:\\Users\\x\\code\\myproj'.length, + 'Windows path should be reduced to a readable name, got: ' + result); +}); + +test('whitespace-only project returns unknown', () => { + assert.equal(displayProject({ project: ' ' }), 'unknown'); +});