From 2cc0ff22a731ca25efaf0fcce574ee881b3167e3 Mon Sep 17 00:00:00 2001 From: jackrescuer-gif Date: Fri, 15 May 2026 13:19:06 +0300 Subject: [PATCH] feat: subscriptions management for all 7 agents + fix project name display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 '; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; + // Add form — native selects for Service and Plan (Plan becomes free-text input for API custom) + var serviceOpts = '' + + 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'); +});