@subvalue/cli 0.1.2 → 0.1.4

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.
Files changed (40) hide show
  1. package/PRE_RELEASE.md +6 -6
  2. package/README.md +7 -5
  3. package/dist/PRICING.md +15 -16
  4. package/dist/packages/cli/src/index.js +109 -20
  5. package/dist/packages/cli/src/protocol.js +13 -0
  6. package/dist/packages/cli/src/runtime.js +44 -14
  7. package/dist/packages/cli/src/server.js +306 -62
  8. package/dist/packages/core/src/calendar.js +47 -0
  9. package/dist/packages/core/src/coverage.js +12 -0
  10. package/dist/packages/core/src/demo.js +61 -9
  11. package/dist/packages/core/src/metadata.js +52 -9
  12. package/dist/packages/core/src/pricing/current.js +346 -297
  13. package/dist/packages/core/src/pricing/index.js +373 -73
  14. package/dist/packages/core/src/providers/claude/index.js +93 -21
  15. package/dist/packages/core/src/providers/codex/index.js +213 -46
  16. package/dist/packages/core/src/scanner.js +226 -46
  17. package/dist/packages/core/src/security.js +127 -34
  18. package/dist/packages/core/src/storage.js +215 -47
  19. package/dist/packages/core/src/subscriptions.js +146 -21
  20. package/dist/packages/core/src/summary.js +409 -51
  21. package/dist/packages/core/src/types.js +71 -17
  22. package/dist/packages/core/src/usage-json.js +166 -47
  23. package/dist/packages/receipt/src/index.js +387 -80
  24. package/dist/packages/ui/src/brand.js +48 -3
  25. package/dist/public/app.css +2280 -56
  26. package/dist/public/apps/web/src/landing.js +200 -8
  27. package/dist/public/apps/web/src/main.js +1143 -63
  28. package/dist/public/index.html +24 -1
  29. package/dist/public/landing.css +3235 -70
  30. package/dist/public/landing.html +21 -1
  31. package/dist/public/logo/wordmark-monochrome.svg +1 -0
  32. package/dist/public/logo/wordmark.svg +1 -0
  33. package/dist/public/packages/core/src/calendar.js +47 -0
  34. package/dist/public/packages/core/src/coverage.js +12 -0
  35. package/dist/public/packages/core/src/subscriptions.js +146 -21
  36. package/dist/public/packages/receipt/src/index.js +387 -80
  37. package/dist/public/packages/ui/src/brand.js +48 -3
  38. package/dist/public/privacy.css +197 -6
  39. package/dist/public/privacy.html +1 -1
  40. package/package.json +1 -1
@@ -1,67 +1,1147 @@
1
1
 
2
- import {product,productMark,productNameMarkup} from '../../../packages/ui/src/brand.js';
3
- document.title=product.name+' — Local usage';
4
-
5
- import {subscriptionPlans,subscriptionPlan,subscriptionPlanLabel,subscriptionVerified} from '../../../packages/core/src/subscriptions.js';
6
-
7
- import {drawReceipt,receiptModel,receiptFonts,exportReceipt,money,comparisonForDisplay} from '../../../packages/receipt/src/index.js';
8
- const app=document.querySelector ('#app') ;
9
- const name=(p )=>p==='codex'?'Codex':'Claude Code';
10
- const esc=(s )=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c] ));
11
- const icon=(key )=>{const paths ={bars:'M5 20V14 M12 20V8 M19 20V3',card:'M3 5h18v14H3z M3 9h18 M6 14h4',overview:'M3 10 12 3l9 7v10H3z M9 20v-7h6v7',receipt:'M6 3h12v18l-3-2-3 2-3-2-3 2z M9 8h6 M9 12h6',settings:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M12 2v3 M12 19v3 M2 12h3 M19 12h3 M5 5l2 2 M17 17l2 2 M5 19l2-2 M17 7l2-2',arrow:'M4 12h16 M14 6l6 6-6 6',download:'M12 3v12 M7 10l5 5 5-5 M4 17v4h16v-4',shield:'M12 3 4 6v6c0 5 8 9 8 9s8-4 8-9V6z',token:'M12 3 21 8v8l-9 5-9-5V8z M3 8l9 5 9-5 M12 13v8',check:'m5 12 4 4 10-10',refresh:'M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 13 2 M18 18a8 8 0 0 1-13-2'};return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${paths[key]??paths.token}"/></svg>`;};
12
- let settings ,sources =[],summary ,token='',demo=false,lastScan =null;let page=location.pathname==='/receipt'?'receipt':location.pathname==='/settings'?'settings':'overview';let period='month',from='',to='',onboardingStage='found';let busy=false;let themeSaving=false;let displayedReceipt ;
13
- async function api (url ,method='GET',body ) {const response=await fetch(url,{method,headers:{'Content-Type':'application/json','X-Subvalue-Token':token},body:body===undefined?undefined:JSON.stringify(body)});if(!response.ok)throw new Error('Unable to complete request');return response.json();}
14
- function toast(text ){const el=document.querySelector ('#toast') ;el.textContent=text;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),3000);}
15
- let loadVersion=0;async function load(){const version=++loadVersion;const next=await api (`/api/summary?period=${period}${period==='custom'?`&from=${from}&to=${to}`:''}`);if(version!==loadVersion)return false;summary=next;return true;}
16
- function metric(label ,value ,detail='',accent=''){const symbol=label==='Subscription'?'card':label==='Value multiple'?'bars':label.includes('value')||label==='Value'?'shield':'token';return `<article class="metric ${accent}"><span class="label">${icon(symbol)}${label}</span><strong>${value}</strong>${detail?`<span class="metric-detail">${detail}</span>`:''}</article>`;}
17
- function navigation(){return `<header class="topbar"><a href="/" class="brand" aria-label="${esc(product.name)} overview"><span class="brand-mark">${productMark}</span>${productNameMarkup}</a><nav aria-label="Main navigation">${['overview','receipt','settings'].map(p=>`<button data-page="${p}" class="nav-item ${page===p?'active':''}" ${page===p?'aria-current="page"':''}>${icon(p)}<span>${p[0].toUpperCase()+p.slice(1)}</span></button>`).join('')}</nav><div class="header-period">${controls()}</div><span class="local-status" aria-label="Local only">${icon('shield')}</span></header>`;}
18
- function controls(){return `<div class="date-controls" aria-label="Period">${[['7d','7D'],['30d','30D'],['month','Month'],['custom','Custom']].map(([v,l])=>`<button data-period="${v}" class="${period===v?'selected':''}" aria-pressed="${period===v}">${l}</button>`).join('')}</div>${period==='custom'?`<form id="custom-range" class="custom-range"><label>From<input type="date" name="from" value="${from}" required></label><label>To<input type="date" name="to" value="${to}" required></label><button class="button small">Apply</button></form>`:''}`;}
19
- function coverage(){return `<details class="coverage"><summary>${icon('shield')}<span>${summary.historyPartial?'Partial history':summary.confidence==='HIGH'?'High confidence':'Incomplete'}${summary.total.priceCoverage!==null?` · ${Math.round(summary.total.priceCoverage*100)}% priced`:''}</span><span class="details-label">Details</span></summary><div class="coverage-content"><dl><div><dt>Confidence</dt><dd>${esc(summary.confidence)}</dd></div><div><dt>Usage events</dt><dd>${summary.total.records.toLocaleString()}</dd></div><div><dt>Priced events</dt><dd>${summary.total.pricedRecords.toLocaleString()}</dd></div><div><dt>Pricing version</dt><dd>${esc(summary.pricingVersion)}</dd></div></dl><p>Coverage describes imported records, not all requests ever made. Missing history stays unknown. Subscription amounts are prorated by calendar day.</p>${summary.providers.filter(p=>p.records||Object.keys(p.issues).length).map(p=>`<div class="issue-group"><strong>${name(p.provider)}</strong>${Object.entries(p.issues).map(([k,n])=>`<span>${esc(k.replaceAll('-',' '))} <b>${n.toLocaleString()}</b></span>`).join('')||'<span>All imported events priced</span>'}</div>`).join('')}${summary.unresolvedModels.length?`<p>Unresolved models: <code>${summary.unresolvedModels.map(esc).join(', ')}</code></p>`:''}${summary.models.map(m=>`<div class="model-coverage"><code>${esc(m.raw)}</code><span>${m.tokens===null?"Unknown tokens":m.tokens.toLocaleString()+" tokens"}</span><span>${m.pricedRecords}/${m.records} priced</span>${m.mappingSource?`<a href="${esc(m.mappingSource)}" target="_blank" rel="noreferrer">${esc(m.mappingConfidence)} mapping ↗</a>`:"<span>Mapping unavailable</span>"}</div>`).join('')}</div></details>`;}
20
- function chart(){const daily=summary.daily;const values=daily.flatMap(d=>[d.codex,d.claude]).filter((n) =>n!==null);if(!values.length)return `<section class="panel chart"><div class="panel-title"><h2>Daily API equivalent</h2><span class="label">${summary.range.label}</span></div><div class="chart-empty"><span class="empty-chart-lines"></span><span>${summary.total.records?'No priced usage':'No data for this period'}</span></div></section>`;
21
- const max=Math.max(...daily.map(d=>(d.codex??0)+(d.claude??0)),.01);const w=700,h=186,pad=30,barArea=620;const calendarDays=(Date.parse(daily.at(-1) .date)-Date.parse(daily[0].date))/864e5+1;const bars=daily.map((d,index)=>{const step=barArea/calendarDays,bw=Math.min(18,step*.7),x=55+(Date.parse(d.date)-Date.parse(daily[0].date))/864e5*step;const a=(d.codex??0)/max*140,b=(d.claude??0)/max*140;const label=dailyDescription(index);return `<g data-chart-day="${index}" tabindex="0" role="button" aria-label="${esc(label)}" aria-describedby="chart-tooltip"><rect x="${x}" y="${h-pad-a}" width="${bw}" height="${a}" rx="1" fill="url(#bar-green)"/><rect x="${x}" y="${h-pad-a-b}" width="${bw}" height="${b}" rx="1" fill="#8aadc2"/><rect class="chart-hit-target" x="${x-3}" y="16" width="${bw+6}" height="140" fill="transparent"/></g>`;}).join('');
22
- const labels=[0,.5,1].map(f=>`<line x1="45" x2="690" y1="${h-pad-f*140}" y2="${h-pad-f*140}" stroke="#242a2e"/><text x="0" y="${h-pad-f*140+4}" fill="#899097" font-size="10">$${(max*f).toFixed(max<1?2:0)}</text>`).join('');
23
- return `<section class="panel chart"><div class="panel-title"><h2>Daily API equivalent</h2><div class="legend">${summary.providers.some(p=>p.provider==='codex'&&p.records)?'<span><i></i>Codex</span>':''}${summary.providers.some(p=>p.provider==='claude'&&p.records)?'<span><i class="claude-dot"></i>Claude Code</span>':''}${summary.total.apiEquivalent===null?'<span>Known portion</span>':''}</div></div><svg viewBox="0 0 ${w} ${h}" class="chart-svg" role="group" aria-label="Daily known API-equivalent value"><defs><linearGradient id="bar-green" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#37ec85"/><stop offset="100%" stop-color="#278451"/></linearGradient></defs>${labels}${bars}</svg><div id="chart-tooltip" class="chart-tooltip" role="tooltip" hidden></div><div class="chart-dates"><span>${daily[0].date}</span><span>${daily.at(-1) .date}</span></div></section>`;}
24
- function dailyDescription(index ){const day=summary.daily[index];return day.date+' · '+summary.providers.filter(p=>p.records).map(p=>name(p.provider)+': '+(day[p.provider]===null?'No priced data':money(day[p.provider]))).join(' · ');}
25
- function bindChart(){
26
- const chart=document.querySelector ('.chart'),tip=document.querySelector ('#chart-tooltip');if(!chart||!tip)return;
27
- const hide=()=>{tip.hidden=true;chart.querySelector('[data-chart-active]')?.removeAttribute('data-chart-active');};
28
- const show=(bar )=>{const day=summary.daily[Number(bar.dataset.chartDay)];if(!day)return;
29
- const amounts=[day.codex,day.claude].filter((v) =>v!==null),total=amounts.length?amounts.reduce((a,b)=>a+b,0):null;
30
- tip.innerHTML=`<strong>${esc(new Date(day.date+'T12:00:00').toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}))}</strong><dl>${summary.providers.filter(p=>p.records).map(p=>`<div><dt>${name(p.provider)}</dt><dd>${day[p.provider]===null?'—':money(day[p.provider])}</dd></div>`).join('')}<div class="chart-tip-total"><dt>Priced value</dt><dd>${total===null?'—':money(total)}</dd></div></dl>`;
31
- tip.hidden=false;chart.querySelector('[data-chart-active]')?.removeAttribute('data-chart-active');bar.setAttribute('data-chart-active','');
32
- const panel=chart.getBoundingClientRect(),bounds=bar.getBoundingClientRect();tip.style.left=Math.max(10,Math.min(chart.clientWidth-tip.offsetWidth-10,bounds.left-panel.left+bounds.width/2-tip.offsetWidth/2))+'px';tip.style.top=Math.max(48,bounds.top-panel.top-tip.offsetHeight-8)+'px';
2
+ import { product, productMark, productNameMarkup } from '../../../packages/ui/src/brand.js';
3
+ document.title = product.name + ' — Local usage';
4
+
5
+ import {
6
+ subscriptionPlans,
7
+ subscriptionPlan,
8
+ subscriptionPlanLabel,
9
+ subscriptionVerified,
10
+ } from '../../../packages/core/src/subscriptions.js';
11
+
12
+
13
+ import {
14
+ fullCalendarMonths,
15
+ shiftCalendarDays,
16
+ shiftCalendarMonth,
17
+ } from '../../../packages/core/src/calendar.js';
18
+ import { formatPricingCoverage } from '../../../packages/core/src/coverage.js';
19
+ import {
20
+ drawReceipt,
21
+ receiptModel,
22
+ receiptFonts,
23
+ exportReceipt,
24
+ money,
25
+ comparisonForDisplay,
26
+ } from '../../../packages/receipt/src/index.js';
27
+ const app = document.querySelector ('#app') ;
28
+ const name = (p ) => (p === 'codex' ? 'Codex' : 'Claude Code');
29
+ const esc = (s ) =>
30
+ String(s).replace(
31
+ /[&<>"']/g,
32
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ,
33
+ );
34
+ const icon = (key ) => {
35
+ const paths = {
36
+ bars: 'M5 20V14 M12 20V8 M19 20V3',
37
+ card: 'M3 5h18v14H3z M3 9h18 M6 14h4',
38
+ overview: 'M3 10 12 3l9 7v10H3z M9 20v-7h6v7',
39
+ receipt: 'M6 3h12v18l-3-2-3 2-3-2-3 2z M9 8h6 M9 12h6',
40
+ settings:
41
+ 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M12 2v3 M12 19v3 M2 12h3 M19 12h3 M5 5l2 2 M17 17l2 2 M5 19l2-2 M17 7l2-2',
42
+ arrow: 'M4 12h16 M14 6l6 6-6 6',
43
+ previous: 'm15 6-6 6 6 6',
44
+ next: 'm9 6 6 6-6 6',
45
+ download: 'M12 3v12 M7 10l5 5 5-5 M4 17v4h16v-4',
46
+ shield: 'M12 3 4 6v6c0 5 8 9 8 9s8-4 8-9V6z',
47
+ token: 'M12 3 21 8v8l-9 5-9-5V8z M3 8l9 5 9-5 M12 13v8',
48
+ check: 'm5 12 4 4 10-10',
49
+ refresh: 'M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 13 2 M18 18a8 8 0 0 1-13-2',
33
50
  };
34
- chart.querySelectorAll ('[data-chart-day]').forEach(bar=>{bar.addEventListener('pointerenter',()=>show(bar));bar.addEventListener('pointerleave',hide);bar.addEventListener('focus',()=>show(bar));bar.addEventListener('blur',hide);bar.addEventListener('click',()=>show(bar));bar.addEventListener('keydown',event=>{if(event.key==='Escape'){hide();event.preventDefault();}else if(event.key==='Enter'||event.key===' '){show(bar);event.preventDefault();}});});
35
- chart.addEventListener('pointerleave',hide);
36
- }
37
- function providerCards(){return `<div class="provider-grid">${summary.providers.filter(p=>p.visible).map(p=>`<article class="panel provider-card"><div class="provider-title">${p.provider==='claude'?'<img class="claude-code-product-mark" src="/brands/claude-code-clawd.svg" alt="" aria-hidden="true">':'<img class="codex-product-mark" src="/brands/codex-outline.svg" alt="" aria-hidden="true">'}<h2 class="${p.provider==='claude'?'claude-code-product-name':'codex-product-name'}">${name(p.provider)}</h2><span class="badge ${p.records?'green':''}">${p.status==='USAGE_FOUND'?'Usage found':p.status==='NO_USAGE'?'No usage':'No data'}</span></div><div class="provider-values"><div><span class="label">API equivalent</span><strong>${p.records?(p.knownSubtotal!==null?money(p.apiEquivalent??p.knownSubtotal):'—'):'No data'}</strong>${p.apiEquivalent===null&&p.knownSubtotal!==null?`<small>${Math.round((p.priceCoverage??0)*100)}% priced</small>`:''}</div><div><span class="label">${p.tokensPartial?'Known tokens':'Tokens'}</span><strong>${p.tokens===null?'Unknown':new Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(p.tokens)}</strong></div></div></article>`).join('')}</div>`;}
38
- function receiptPanel(compact=false){const model=displayedReceipt=receiptModel(summary);return `<section class="receipt-panel ${compact?'compact':''}" aria-label="AI usage receipt"><div class="receipt-theme-control" role="group" aria-label="Receipt color">${(['dark','light'] ).map(theme=>`<button type="button" data-receipt-theme="${theme}" aria-pressed="${(settings.receiptTheme??'dark')===theme}" aria-label="${theme==='dark'?'Black':'White'} receipt" ${themeSaving?'disabled':''}>${theme==='dark'?'Black':'White'}</button>`).join('')}</div><canvas class="receipt-canvas" id="receipt-canvas" role="img" aria-label="${esc(model.period+' · Generated '+model.generated+' · '+model.rows.map(r=>r.left+' '+r.right).join(' · ')+' · '+model.outcome+' · '+model.confidence)}"></canvas><button class="button export-button" id="export-png">${icon('download')}Export PNG</button></section>`;}
39
- function refreshButton(){return `<button type="button" class="refresh-button" id="rescan" ${busy?'disabled':''} aria-busy="${busy}" title="${lastScan?'Last scan: '+esc(new Date(lastScan).toLocaleString()):'Scan local usage'}">${icon('refresh')}<span>${busy?'Scanning…':'Refresh usage'}</span></button>`;}
40
- function updateRefreshButton(){const button=document.querySelector ('#rescan');if(button){button.disabled=busy;button.setAttribute('aria-busy',String(busy));button.innerHTML=icon('refresh')+'<span>'+(busy?'Scanning…':'Refresh usage')+'</span>';}}
41
- function overview(){const t=summary.total,c=comparisonForDisplay(summary);const active=summary.providers.filter(p=>p.records);const showSubs=active.length&&active.every(p=>p.mode==='SUBSCRIPTION');return `<div class="page-heading"><div><h1>Overview</h1></div>${refreshButton()}</div>${coverage()}<div class="overview-layout"><div class="overview-main"><div class="metrics ${showSubs?'':'single'}">${metric('API equivalent',t.apiEquivalent===null?(t.knownSubtotal!==null?money(t.knownSubtotal):t.records?'—':'No data'):money(t.apiEquivalent),summary.range.label,'api-metric')}${showSubs?metric('Subscription',c.subscription===null?'Not set':money(c.subscription),period==='month'?'Per month':'Prorated'):''}${showSubs?metric(summary.historyPartial?'Observed value':'Value',c.value===null?'—':money(c.value,true),c.value===null?'Comparison unavailable':summary.historyPartial?'Partial history':'',c.value!==null?(c.value>=0?'positive':'negative'):''):''}${showSubs?metric('Value multiple',c.roi===null?'—':`${c.roi.toFixed(2)}<span class="multiply">×</span>`,c.breakEven?`Break-even ${new Date(c.breakEven+'T12:00:00').toLocaleDateString('en-US',{month:'short',day:'numeric'})}`:c.roi===null?'Unavailable':t.apiEquivalent===null?'Priced portion':''):''}</div>${providerCards()}${chart()}<div class="overview-bottom"><span>${icon('shield')}Local analysis. Read-only sources.</span><button class="text-button" data-page="settings">Manage sources ${icon('arrow')}</button></div>${!t.records?'<section class="panel empty-panel"><h2>No data for this period</h2><button class="text-button" data-page="settings">View sources →</button></section>':''}</div>${receiptPanel(true)}</div>`;}
42
- function receiptPage(){return `<div class="page-heading"><div><h1>Receipt</h1></div></div><div class="receipt-page-layout">${receiptPanel()}<aside class="receipt-aside"><span class="eyebrow">API EQUIVALENT</span><strong>${summary.total.knownSubtotal!==null?money(summary.total.apiEquivalent??summary.total.knownSubtotal):summary.total.records?'No priced usage':'No data'}</strong><span>${summary.range.label}</span>${coverage()}<details class="privacy-note"><summary>Privacy</summary><p>Exports contain provider totals and the selected period. No projects, sessions, paths, prompts, or responses.</p></details></aside></div>`;}
43
- function billingFields(p ,firstRun=false){const b=settings.billing[p],plan=subscriptionPlan(p,b.planId);return `<div class="billing-row"><label class="provider-setting-name" for="mode-${p}">${name(p)}</label><select id="mode-${p}" name="mode-${p}" data-billing-mode="${p}" ${firstRun?'required data-first-run="true"':''}><option value="${firstRun?'':'UNKNOWN'}" ${b.mode==='UNKNOWN'?'selected':''}>${firstRun?'Choose usage':'Not set'}</option><option value="SUBSCRIPTION" ${b.mode==='SUBSCRIPTION'?'selected':''}>Subscription</option><option value="API" ${b.mode==='API'?'selected':''}>API</option><option value="MIXED" ${b.mode==='MIXED'?'selected':''}>Mixed / not sure</option></select><div class="subscription-fields ${b.mode==='SUBSCRIPTION'?'':'hidden'}" id="amount-${p}"><label class="sr-only" for="plan-${p}">${name(p)} subscription plan</label><select id="plan-${p}" name="plan-${p}" data-subscription-plan="${p}"><option value="custom" ${!plan?'selected':''}>Custom amount</option>${subscriptionPlans.filter(plan=>plan.provider===p).map(option=>`<option value="${option.id}" ${plan?.id===option.id?'selected':''}>${esc(subscriptionPlanLabel(option))}</option>`).join('')}</select><label class="amount-field"><span class="sr-only">${name(p)} monthly subscription in USD</span><span>$</span><input name="monthly-${p}" type="number" min="0" max="1000000" step="0.01" value="${plan?plan.monthly.toFixed(2):b.monthly??''}" ${plan?'readonly':''} ${firstRun&&b.mode==='SUBSCRIPTION'?'required':''} placeholder="Amount"><span>/ month</span></label></div></div>`;}
44
- function settingsPage(){return `<div class="page-heading"><div><h1>Settings</h1></div></div><div class="settings-layout"><form id="settings-form"><section class="panel settings-section"><h2>Sources</h2>${summary.providers.map(p=>{const s=sources.find(s=>s.provider===p.provider);return `<div class="source-row"><div><strong>${name(p.provider)}</strong><span class="source-state">${p.status==='USAGE_FOUND'?'Usage found':p.detected?'No usage in selected period':'Not detected'}</span></div><label class="include-check"><input type="checkbox" name="include-${p.provider}" ${settings.include[p.provider]?'checked':''}>Always show</label><details><summary>Details</summary><dl><div><dt>Detection</dt><dd>${p.detected?'Detected':'Not detected'}</dd></div><div><dt>Imported events</dt><dd>${p.importedRecords.toLocaleString()}</dd></div><div><dt>Last scan</dt><dd>${lastScan?new Date(lastScan).toLocaleString():'Not scanned'}</dd></div><div><dt>Source files</dt><dd>${s?.files??0}</dd></div><div><dt>Available history</dt><dd>${p.first?p.first.slice(0,10)+' → '+p.last?.slice(0,10):'No data'}</dd></div><div><dt>Read errors</dt><dd>${s?.errors??0}</dd></div><div><dt>Malformed / skipped</dt><dd>${(s?.diagnostics.malformed??0)+(s?.diagnostics.oversize??0)}</dd></div><div><dt>Incomplete final line</dt><dd>${s?.diagnostics.partialTail?'Yes':'No'}</dd></div></dl></details></div>`;}).join('')}${refreshButton()}</section><section class="panel settings-section"><h2>Billing</h2>${(['codex','claude'] ).filter(p=>sources.find(s=>s.provider===p)?.detected).map(p=>billingFields(p)).join('')}<details class="compact-details"><summary>Details</summary><p>Plans verified ${subscriptionVerified}. ${subscriptionPlans.filter((p,i,list)=>list.findIndex(q=>q.source===p.source)===i).map(p=>`<a href="${esc(p.source)}" target="_blank" rel="noreferrer">${p.provider==='codex'?'ChatGPT':'Claude'} ↗</a>`).join(' · ')}. Annual plans use the exact annual price divided by 12; team plans are per seat. Enterprise / Edu: custom amount.</p><p>These are your declarations, not inferred billing history. Amounts are in USD and prorated by calendar day across the selected period. API and mixed usage have no subscription comparison.</p></details></section><section class="panel settings-section"><h2>Currency</h2><div class="setting-line"><span>API prices & subscription</span><strong>USD</strong></div></section><section class="panel settings-section"><h2>Privacy / data</h2><div class="privacy-tags"><span>${icon('check')}Local only</span><span>${icon('check')}Read-only sources</span><span>${icon('check')}No telemetry</span></div><details><summary>Data access</summary><p>Only approved Codex and Claude JSONL session files are streamed. Only normalized usage metadata is stored. Credentials, project contents and conversation content are not stored.</p><code>~/.codex/sessions/**/*.jsonl<br>~/.codex/archived_sessions/*.jsonl<br>~/.claude/projects/**/*.jsonl</code></details></section><div class="form-actions"><button class="button" type="submit">Save changes</button></div></form></div>`;}
45
- function onboarding(){const found=summary.providers.filter(p=>p.first),billable=summary.providers.filter(p=>p.importedRecords>0);return `<dialog id="onboarding" aria-labelledby="onboarding-title"><span class="brand-mark">${productMark}</span><span class="eyebrow">${esc(product.name.toUpperCase())} / FIRST RUN</span><h2 id="onboarding-title">${onboardingStage==='found'?(found.length?'Local usage found':'No local usage found'):'How did you use it?'}</h2>${onboardingStage==='found'?`<div class="found-sources">${summary.providers.map(p=>`<div><strong>${name(p.provider)}</strong><span>${p.first?`${Math.max(1,Math.ceil((Date.parse(p.last )-Date.parse(p.first))/864e5))} days ${icon('check')}`:p.detected?'No data found':'Not detected'}</span></div>`).join('')}</div><button class="button" id="onboarding-continue">Continue ${icon('arrow')}</button>`:`<form id="onboarding-billing">${billable.map(p=>`<h3>How did you use ${name(p.provider)}?</h3>${billingFields(p.provider,true)}`).join('')}<button class="button" type="submit">Open overview ${icon('arrow')}</button></form><button class="text-button onboarding-skip" id="onboarding-skip">Set up later</button>`}</dialog>`;}
46
- function render(){app.innerHTML=`${demo?'<div class="demo-banner">DEMO / FIXTURE DATA — no local sources accessed</div>':''}${!settings.onboarded?onboarding():`<div class="app-shell">${navigation()}<main class="app-main">${page==='overview'?overview():page==='receipt'?receiptPage():settingsPage()}</main><footer class="app-footer"><span>${esc(product.name)} <span class="version">0.1</span></span><span>LOCAL / READ-ONLY</span></footer></div>`}`;
47
- const canvas=document.querySelector ('#receipt-canvas');if(canvas)drawReceipt(canvas,displayedReceipt,settings.receiptTheme??'dark');const dialog=document.querySelector ('#onboarding');if(dialog){dialog.addEventListener('cancel',event=>event.preventDefault());dialog.showModal();}bind();}
48
- function readBilling(form ){const data=new FormData(form);for(const p of ['codex','claude'] ){const mode=data.get(`mode-${p}`);if(mode){const amount=data.get(`monthly-${p}`),plan=subscriptionPlan(p,String(data.get(`plan-${p}`)));settings.billing[p]={mode:mode ,monthly:mode==='SUBSCRIPTION'?(plan?.monthly??(amount!==''&&amount!==null?Number(amount):null)):null,...(mode==='SUBSCRIPTION'?{planId:plan?.id??null}:{})};}}}
49
- async function save(){await api('/api/settings','POST',settings);await load();render();}
50
- function bind(){document.querySelectorAll ('[data-page]').forEach(b=>b.addEventListener('click',()=>{page=b.dataset.page ;history.replaceState(null,'',page==='overview'?'/':`/${page}`);render();window.scrollTo(0,0);}));document.querySelectorAll ('[data-period]').forEach(b=>b.addEventListener('click',async()=>{if(busy)return;period=b.dataset.period ;if(period==='custom'&&(!from||!to)){from=new Date(summary.range.from).toLocaleDateString('en-CA');to=new Date(Date.parse(summary.range.until)-864e5).toLocaleDateString('en-CA');render();return;}try{if(await load())render();}catch{toast('Choose a valid date range');}}));
51
+ return /* HTML */ `<svg
52
+ viewBox="0 0 24 24"
53
+ fill="none"
54
+ stroke="currentColor"
55
+ stroke-width="1.5"
56
+ stroke-linecap="round"
57
+ stroke-linejoin="round"
58
+ aria-hidden="true"
59
+ >
60
+ <path d="${paths[key] ?? paths.token}" />
61
+ </svg>`;
62
+ };
63
+ let settings ,
64
+ sources = [],
65
+ summary ,
66
+ token = '',
67
+ demo = false,
68
+ lastScan = null;
69
+ let page =
70
+ location.pathname === '/receipt'
71
+ ? 'receipt'
72
+ : location.pathname === '/settings'
73
+ ? 'settings'
74
+ : 'overview';
75
+ let period = 'month',
76
+ from = '',
77
+ to = '',
78
+ onboardingStage = 'found';
79
+ let anchor = '';
80
+ let selectedMonth = localDate(new Date()).slice(0, 7),
81
+ periodLoading = false;
82
+ let busy = false;
83
+ let themeSaving = false;
84
+ let displayedReceipt ;
85
+ function localDate(date ) {
86
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
87
+ }
88
+ function periodLabel() {
89
+ return (
90
+ summary.range.label +
91
+ (!summary.demo &&
92
+ Date.parse(summary.range.from) <= Date.now() &&
93
+ Date.parse(summary.range.until) > Date.now() &&
94
+ fullCalendarMonths(summary.range) !== null
95
+ ? ' · In progress'
96
+ : '')
97
+ );
98
+ }
99
+ async function api (url , method = 'GET', body ) {
100
+ const response = await fetch(url, {
101
+ method,
102
+ headers: { 'Content-Type': 'application/json', 'X-Subvalue-Token': token },
103
+ body: body === undefined ? undefined : JSON.stringify(body),
104
+ });
105
+ if (!response.ok) throw new Error('Unable to complete request');
106
+ return response.json();
107
+ }
108
+ function toast(text ) {
109
+ const el = document.querySelector ('#toast') ;
110
+ el.textContent = text;
111
+ el.classList.add('show');
112
+ setTimeout(() => el.classList.remove('show'), 3000);
113
+ }
114
+ let loadVersion = 0;
115
+ async function load() {
116
+ const version = ++loadVersion;
117
+ const next = await api (
118
+ `/api/summary?period=${period}${period === 'custom' ? `&from=${from}&to=${to}` : period === 'month' ? `&month=${selectedMonth}` : anchor ? `&anchor=${anchor}` : ''}`,
119
+ );
120
+ if (version !== loadVersion) return false;
121
+ summary = next;
122
+ return true;
123
+ }
124
+ function metric(label , value , detail = '', accent = '') {
125
+ const symbol =
126
+ label === 'Subscription'
127
+ ? 'card'
128
+ : label === 'Value multiple'
129
+ ? 'bars'
130
+ : label.includes('value') || label === 'Value'
131
+ ? 'shield'
132
+ : 'token';
133
+ return /* HTML */ `<article class="metric ${accent}">
134
+ <span class="label">${icon(symbol)}${label}</span
135
+ ><strong>${value}</strong
136
+ >${detail ? /* HTML */ `<span class="metric-detail">${detail}</span>` : ''}
137
+ </article>`;
138
+ }
139
+ function navigation() {
140
+ return /* HTML */ `<header class="topbar">
141
+ <a href="/" class="brand" aria-label="${esc(product.name)} overview"
142
+ ><span class="brand-mark">${productMark}</span>${productNameMarkup}</a
143
+ >
144
+ <nav aria-label="Main navigation">
145
+ ${['overview', 'receipt', 'settings'].map((p) => /* HTML */ `<button data-page="${p}" class="nav-item ${page === p ? 'active' : ''}" ${page === p ? 'aria-current="page"' : ''}>${icon(p)}<span>${p[0].toUpperCase() + p.slice(1)}</span></button>`).join('')}
146
+ </nav>
147
+ <div class="header-period">${controls()}</div>
148
+ <span class="local-status" aria-label="Local only">${icon('shield')}</span>
149
+ </header>`;
150
+ }
151
+ function adjacentPeriod(direction ) {
152
+ if (period === 'month') {
153
+ const month = shiftCalendarMonth(selectedMonth, direction);
154
+ return month ? { month, from, to, anchor: '' } : null;
155
+ }
156
+ const first = summary.range.calendarFrom ?? localDate(new Date(summary.range.from)),
157
+ last = summary.range.calendarTo ?? localDate(new Date(Date.parse(summary.range.until) - 1));
158
+ const days =
159
+ period === '7d'
160
+ ? 7
161
+ : period === '30d'
162
+ ? 30
163
+ : Math.round((Date.parse(last + 'T12:00:00Z') - Date.parse(first + 'T12:00:00Z')) / 864e5) +
164
+ 1;
165
+ const nextFrom = shiftCalendarDays(first, direction * days),
166
+ nextTo = shiftCalendarDays(last, direction * days);
167
+ if (
168
+ (period === '7d' || period === '30d') &&
169
+ nextTo &&
170
+ !/^(?:19|20|21)\d{2}-\d{2}-\d{2}$/.test(nextTo)
171
+ )
172
+ return null;
173
+ return nextFrom && nextTo
174
+ ? { month: selectedMonth, from: nextFrom, to: nextTo, anchor: nextTo }
175
+ : null;
176
+ }
177
+ function periodArrow(direction ) {
178
+ const unit =
179
+ period === 'month'
180
+ ? 'month'
181
+ : period === '7d'
182
+ ? '7 days'
183
+ : period === '30d'
184
+ ? '30 days'
185
+ : 'period',
186
+ label = (direction < 0 ? 'Previous ' : 'Next ') + unit;
187
+ return /* HTML */ `<button
188
+ class="period-arrow"
189
+ data-period-step="${direction}"
190
+ type="button"
191
+ aria-label="${label}"
192
+ title="${label}"
193
+ ${busy || !adjacentPeriod(direction) ? 'disabled' : ''}
194
+ >
195
+ ${icon(direction < 0 ? 'previous' : 'next')}
196
+ </button>`;
197
+ }
198
+ function controls() {
199
+ return /* HTML */ `<div class="period-selection" role="group" aria-label="Navigate periods">
200
+ ${periodArrow(-1)}
201
+ <div class="date-controls" aria-label="Period">
202
+ ${[
203
+ ['7d', '7D'],
204
+ ['30d', '30D'],
205
+ ['month', 'Month'],
206
+ ['custom', 'Custom'],
207
+ ]
208
+ .map(
209
+ ([v, l]) =>
210
+ /* HTML */ `<button
211
+ data-period="${v}"
212
+ class="${period === v ? 'selected' : ''}"
213
+ aria-pressed="${period === v}"
214
+ ${v === 'month' || v === 'custom' ? 'aria-haspopup="dialog" aria-controls="period-picker" aria-expanded="false"' : ''}
215
+ >
216
+ ${l}
217
+ </button>`,
218
+ )
219
+ .join('')}
220
+ </div>
221
+ ${periodArrow(1)}
222
+ </div>
223
+ <div
224
+ id="period-picker"
225
+ class="period-picker"
226
+ popover="auto"
227
+ role="dialog"
228
+ aria-label="Choose period"
229
+ >
230
+ <form id="month-range" hidden>
231
+ <label for="selected-month">Choose month</label
232
+ ><input
233
+ id="selected-month"
234
+ type="month"
235
+ name="month"
236
+ value="${selectedMonth}"
237
+ pattern="[0-9]{4}-[0-9]{2}"
238
+ placeholder="YYYY-MM"
239
+ required
240
+ /><button class="button small">Apply</button>
241
+ </form>
242
+ <form id="custom-range" hidden>
243
+ <div class="period-date-fields">
244
+ <label
245
+ >From<input
246
+ type="date"
247
+ name="from"
248
+ value="${from || summary.range.calendarFrom || localDate(new Date(summary.range.from))}"
249
+ required /></label
250
+ ><label
251
+ >To<input
252
+ type="date"
253
+ name="to"
254
+ value="${to || summary.range.calendarTo || localDate(new Date(Date.parse(summary.range.until) - 1))}"
255
+ required
256
+ /></label>
257
+ </div>
258
+ <button class="button small">Apply</button>
259
+ </form>
260
+ </div>`;
261
+ }
262
+ function coverage() {
263
+ return /* HTML */ `<details class="coverage">
264
+ <summary>
265
+ ${icon('shield')}<span
266
+ >${summary.historyPartial ? 'Partial history' : summary.confidence === 'HIGH' ? 'High confidence' : 'Incomplete'}${summary.total.priceCoverage !== null ? ` · ${esc(formatPricingCoverage(summary.total.priceCoverage))} priced` : ''}</span
267
+ ><span class="details-label">Details</span>
268
+ </summary>
269
+ <div class="coverage-content">
270
+ <dl>
271
+ <div>
272
+ <dt>Confidence</dt>
273
+ <dd>${esc(summary.confidence)}</dd>
274
+ </div>
275
+ <div>
276
+ <dt>Usage events</dt>
277
+ <dd>${summary.total.records.toLocaleString()}</dd>
278
+ </div>
279
+ <div>
280
+ <dt>Priced events</dt>
281
+ <dd>${summary.total.pricedRecords.toLocaleString()}</dd>
282
+ </div>
283
+ <div>
284
+ <dt>Pricing version</dt>
285
+ <dd>${esc(summary.pricingVersion)}</dd>
286
+ </div>
287
+ </dl>
288
+ <p>
289
+ Coverage describes imported records, not all requests ever made. Missing history stays
290
+ unknown. Subscription comparison is available for full calendar months, at the full monthly
291
+ price.
292
+ </p>
293
+ ${summary.providers
294
+ .filter((p) => p.records || Object.keys(p.issues).length)
295
+ .map(
296
+ (p) =>
297
+ /* HTML */ `<div class="issue-group">
298
+ <strong>${name(p.provider)}</strong>${
299
+ Object.entries(p.issues)
300
+ .map(
301
+ ([k, n]) =>
302
+ /* HTML */ `<span
303
+ >${esc(k.replaceAll('-', ' '))} <b>${n.toLocaleString()}</b></span
304
+ >`,
305
+ )
306
+ .join('') || '<span>All imported events priced</span>'
307
+ }
308
+ </div>`,
309
+ )
310
+ .join(
311
+ '',
312
+ )}${summary.unresolvedModels.length ? /* HTML */ `<p>Unresolved models: <code>${summary.unresolvedModels.map(esc).join(', ')}</code></p>` : ''}${summary.models.map((m) => /* HTML */ `<div class="model-coverage"><code>${esc(m.raw)}</code><span>${m.tokens === null ? 'Unknown tokens' : m.tokens.toLocaleString() + ' tokens'}</span><span>${m.pricedRecords}/${m.records} priced</span>${m.mappingSource ? /* HTML */ `<a href="${esc(m.mappingSource)}" target="_blank" rel="noreferrer">${esc(m.mappingConfidence)} mapping ↗</a>` : '<span>Mapping unavailable</span>'}</div>`).join('')}
313
+ </div>
314
+ </details>`;
315
+ }
316
+ function chart() {
317
+ const daily = summary.daily;
318
+ const values = daily.flatMap((d) => [d.codex, d.claude]).filter((n) => n !== null);
319
+ if (!values.length)
320
+ return /* HTML */ `<section class="panel chart">
321
+ <div class="panel-title">
322
+ <h2>Daily API equivalent</h2>
323
+ <span class="label">${summary.range.label}</span>
324
+ </div>
325
+ <div class="chart-empty">
326
+ <span class="empty-chart-lines"></span
327
+ ><span>${summary.total.records ? 'No priced usage' : 'No data for this period'}</span>
328
+ </div>
329
+ </section>`;
330
+ const max = Math.max(...daily.map((d) => (d.codex ?? 0) + (d.claude ?? 0)), 0.01);
331
+ const w = 700,
332
+ h = 186,
333
+ pad = 30,
334
+ barArea = 620;
335
+ const calendarDays = (Date.parse(daily.at(-1) .date) - Date.parse(daily[0].date)) / 864e5 + 1;
336
+ const bars = daily
337
+ .map((d, index) => {
338
+ const step = barArea / calendarDays,
339
+ bw = Math.min(18, step * 0.7),
340
+ x = 55 + ((Date.parse(d.date) - Date.parse(daily[0].date)) / 864e5) * step;
341
+ const a = ((d.codex ?? 0) / max) * 140,
342
+ b = ((d.claude ?? 0) / max) * 140;
343
+ const label = dailyDescription(index);
344
+ return /* HTML */ `<g
345
+ data-chart-day="${index}"
346
+ tabindex="0"
347
+ role="button"
348
+ aria-label="${esc(label)}"
349
+ aria-describedby="chart-tooltip"
350
+ ><rect
351
+ x="${x}"
352
+ y="${h - pad - a}"
353
+ width="${bw}"
354
+ height="${a}"
355
+ rx="1"
356
+ fill="url(#bar-green)" /><rect
357
+ x="${x}"
358
+ y="${h - pad - a - b}"
359
+ width="${bw}"
360
+ height="${b}"
361
+ rx="1"
362
+ fill="#8aadc2" /><rect
363
+ class="chart-hit-target"
364
+ x="${x - 3}"
365
+ y="16"
366
+ width="${bw + 6}"
367
+ height="140"
368
+ fill="transparent"
369
+ /></g>`;
370
+ })
371
+ .join('');
372
+ const labels = [0, 0.5, 1]
373
+ .map(
374
+ (f) =>
375
+ /* HTML */ `<line
376
+ x1="45"
377
+ x2="690"
378
+ y1="${h - pad - f * 140}"
379
+ y2="${h - pad - f * 140}"
380
+ stroke="#242a2e"
381
+ /><text x="0" y="${h - pad - f * 140 + 4}" fill="#899097" font-size="10"
382
+ >$${(max * f).toFixed(max < 1 ? 2 : 0)}</text
383
+ >`,
384
+ )
385
+ .join('');
386
+ return /* HTML */ `<section class="panel chart">
387
+ <div class="panel-title">
388
+ <h2>Daily API equivalent</h2>
389
+ <div class="legend">
390
+ ${summary.providers.some((p) => p.provider === 'codex' && p.records) ? '<span><i></i>Codex</span>' : ''}${summary.providers.some((p) => p.provider === 'claude' && p.records) ? '<span><i class="claude-dot"></i>Claude Code</span>' : ''}${summary.total.apiEquivalent === null ? '<span>Known portion</span>' : ''}
391
+ </div>
392
+ </div>
393
+ <svg
394
+ viewBox="0 0 ${w} ${h}"
395
+ class="chart-svg"
396
+ role="group"
397
+ aria-label="Daily known API-equivalent value"
398
+ >
399
+ <defs>
400
+ <linearGradient id="bar-green" x1="0" y1="0" x2="0" y2="1">
401
+ <stop offset="0%" stop-color="#37ec85" />
402
+ <stop offset="100%" stop-color="#278451" />
403
+ </linearGradient>
404
+ </defs>
405
+ ${labels}${bars}
406
+ </svg>
407
+ <div id="chart-tooltip" class="chart-tooltip" role="tooltip" hidden></div>
408
+ <div class="chart-dates"><span>${daily[0].date}</span><span>${daily.at(-1) .date}</span></div>
409
+ </section>`;
410
+ }
411
+ function dailyDescription(index ) {
412
+ const day = summary.daily[index];
413
+ return (
414
+ day.date +
415
+ ' · ' +
416
+ summary.providers
417
+ .filter((p) => p.records)
418
+ .map(
419
+ (p) =>
420
+ name(p.provider) +
421
+ ': ' +
422
+ (day[p.provider] === null ? 'No priced data' : money(day[p.provider])),
423
+ )
424
+ .join(' · ')
425
+ );
426
+ }
427
+ function bindChart() {
428
+ const chart = document.querySelector ('.chart'),
429
+ tip = document.querySelector ('#chart-tooltip');
430
+ if (!chart || !tip) return;
431
+ const hide = () => {
432
+ tip.hidden = true;
433
+ chart.querySelector('[data-chart-active]')?.removeAttribute('data-chart-active');
434
+ };
435
+ const show = (bar ) => {
436
+ const day = summary.daily[Number(bar.dataset.chartDay)];
437
+ if (!day) return;
438
+ const amounts = [day.codex, day.claude].filter((v) => v !== null),
439
+ total = amounts.length ? amounts.reduce((a, b) => a + b, 0) : null;
440
+ tip.innerHTML = /* HTML */ `<strong
441
+ >${esc(new Date(day.date + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }))}</strong
442
+ >
443
+ <dl>
444
+ ${summary.providers
445
+ .filter((p) => p.records)
446
+ .map(
447
+ (p) =>
448
+ /* HTML */ `<div>
449
+ <dt>${name(p.provider)}</dt>
450
+ <dd>${day[p.provider] === null ? '—' : money(day[p.provider])}</dd>
451
+ </div>`,
452
+ )
453
+ .join('')}
454
+ <div class="chart-tip-total">
455
+ <dt>Priced value</dt>
456
+ <dd>${total === null ? '—' : money(total)}</dd>
457
+ </div>
458
+ </dl>`;
459
+ tip.hidden = false;
460
+ chart.querySelector('[data-chart-active]')?.removeAttribute('data-chart-active');
461
+ bar.setAttribute('data-chart-active', '');
462
+ const panel = chart.getBoundingClientRect(),
463
+ bounds = bar.getBoundingClientRect();
464
+ tip.style.left =
465
+ Math.max(
466
+ 10,
467
+ Math.min(
468
+ chart.clientWidth - tip.offsetWidth - 10,
469
+ bounds.left - panel.left + bounds.width / 2 - tip.offsetWidth / 2,
470
+ ),
471
+ ) + 'px';
472
+ tip.style.top = Math.max(48, bounds.top - panel.top - tip.offsetHeight - 8) + 'px';
473
+ };
474
+ chart.querySelectorAll ('[data-chart-day]').forEach((bar) => {
475
+ bar.addEventListener('pointerenter', () => show(bar));
476
+ bar.addEventListener('pointerleave', hide);
477
+ bar.addEventListener('focus', () => show(bar));
478
+ bar.addEventListener('blur', hide);
479
+ bar.addEventListener('click', () => show(bar));
480
+ bar.addEventListener('keydown', (event) => {
481
+ if (event.key === 'Escape') {
482
+ hide();
483
+ event.preventDefault();
484
+ } else if (event.key === 'Enter' || event.key === ' ') {
485
+ show(bar);
486
+ event.preventDefault();
487
+ }
488
+ });
489
+ });
490
+ chart.addEventListener('pointerleave', hide);
491
+ }
492
+ function providerCards() {
493
+ return /* HTML */ `<div class="provider-grid">
494
+ ${summary.providers
495
+ .filter((p) => p.visible)
496
+ .map(
497
+ (p) =>
498
+ /* HTML */ `<article class="panel provider-card">
499
+ <div class="provider-title">
500
+ ${p.provider === 'claude' ? '<img class="claude-code-product-mark" src="/brands/claude-code-clawd.svg" alt="" aria-hidden="true">' : '<img class="codex-product-mark" src="/brands/codex-outline.svg" alt="" aria-hidden="true">'}
501
+ <h2
502
+ class="${p.provider === 'claude' ? 'claude-code-product-name' : 'codex-product-name'}"
503
+ >
504
+ ${name(p.provider)}
505
+ </h2>
506
+ <span class="badge ${p.records ? 'green' : ''}"
507
+ >${p.status === 'USAGE_FOUND' ? 'Usage found' : p.status === 'NO_USAGE' ? 'No usage' : 'No data'}</span
508
+ >
509
+ </div>
510
+ <div class="provider-values">
511
+ <div>
512
+ <span class="label">API equivalent</span
513
+ ><strong
514
+ >${p.records ? (p.knownSubtotal !== null ? money(p.apiEquivalent ?? p.knownSubtotal) : '—') : 'No data'}</strong
515
+ >${p.apiEquivalent === null && p.knownSubtotal !== null ? /* HTML */ `<small>${esc(formatPricingCoverage(p.priceCoverage))} priced</small>` : ''}
516
+ </div>
517
+ <div>
518
+ <span class="label">${p.tokensPartial ? 'Known tokens' : 'Tokens'}</span
519
+ ><strong
520
+ >${p.tokens === null ? 'Unknown' : new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(p.tokens)}</strong
521
+ >
522
+ </div>
523
+ </div>
524
+ </article>`,
525
+ )
526
+ .join('')}
527
+ </div>`;
528
+ }
529
+ function receiptPanel(compact = false) {
530
+ const model = (displayedReceipt = receiptModel(summary));
531
+ return /* HTML */ `<section
532
+ class="receipt-panel ${compact ? 'compact' : ''}"
533
+ aria-label="AI usage receipt"
534
+ >
535
+ <div class="receipt-theme-control" role="group" aria-label="Receipt color">
536
+ ${(['dark', 'light'] ).map((theme) => /* HTML */ `<button type="button" data-receipt-theme="${theme}" aria-pressed="${(settings.receiptTheme ?? 'dark') === theme}" aria-label="${theme === 'dark' ? 'Black' : 'White'} receipt" ${themeSaving ? 'disabled' : ''}>${theme === 'dark' ? 'Black' : 'White'}</button>`).join('')}
537
+ </div>
538
+ <canvas
539
+ class="receipt-canvas"
540
+ id="receipt-canvas"
541
+ role="img"
542
+ aria-label="${esc(model.period + ' · ' + model.periodDetail + ' · Generated ' + model.generated + ' · ' + model.rows.map((r) => r.left + ' ' + r.right).join(' · ') + ' · ' + model.outcome + ' · ' + model.confidence)}"
543
+ ></canvas
544
+ ><button class="button export-button" id="export-png">${icon('download')}Export PNG</button>
545
+ </section>`;
546
+ }
547
+ function refreshButton() {
548
+ return /* HTML */ `<button
549
+ type="button"
550
+ class="refresh-button"
551
+ id="rescan"
552
+ ${busy ? 'disabled' : ''}
553
+ aria-busy="${busy}"
554
+ title="${lastScan ? 'Last scan: ' + esc(new Date(lastScan).toLocaleString()) : 'Scan local usage'}"
555
+ >
556
+ ${icon('refresh')}<span>${busy ? 'Scanning…' : 'Refresh usage'}</span>
557
+ </button>`;
558
+ }
559
+ function updateRefreshButton() {
560
+ const button = document.querySelector ('#rescan');
561
+ if (button) {
562
+ button.disabled = busy;
563
+ button.setAttribute('aria-busy', String(busy));
564
+ button.innerHTML =
565
+ icon('refresh') + '<span>' + (busy ? 'Scanning…' : 'Refresh usage') + '</span>';
566
+ }
567
+ }
568
+ function overview() {
569
+ const t = summary.total,
570
+ c = comparisonForDisplay(summary);
571
+ const active = summary.providers.filter((p) => p.records);
572
+ const months = fullCalendarMonths(summary.range),
573
+ showSubs = months !== null && active.length && active.every((p) => p.mode === 'SUBSCRIPTION');
574
+ return /* HTML */ `<div class="page-heading">
575
+ <div><h1>Overview</h1></div>
576
+ ${refreshButton()}
577
+ </div>
578
+ ${coverage()}
579
+ <div class="overview-layout">
580
+ <div class="overview-main">
581
+ <div class="metrics ${showSubs ? '' : 'single'}">
582
+ ${metric('API equivalent', t.apiEquivalent === null ? (t.knownSubtotal !== null ? money(t.knownSubtotal) : t.records ? '—' : 'No data') : money(t.apiEquivalent), periodLabel(), 'api-metric')}${showSubs ? metric('Subscription', c.subscription === null ? 'Not set' : money(c.subscription), months === 1 ? 'Full month' : `${months} full months`) : ''}${showSubs ? metric(summary.historyPartial ? 'Observed value' : 'Value', c.value === null ? '—' : money(c.value, true), c.value === null ? 'Comparison unavailable' : summary.historyPartial ? 'Partial history' : '', c.value !== null ? (c.value >= 0 ? 'positive' : 'negative') : '') : ''}${showSubs ? metric('Value multiple', c.roi === null ? '—' : `${c.roi.toFixed(2)}<span class="multiply">×</span>`, c.breakEven ? `Break-even ${new Date(c.breakEven + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}` : c.roi === null ? 'Unavailable' : t.apiEquivalent === null ? 'Priced portion' : '') : ''}
583
+ </div>
584
+ ${providerCards()}${chart()}
585
+ <div class="overview-bottom">
586
+ <span>${icon('shield')}Local analysis. Read-only sources.</span
587
+ ><button class="text-button" data-page="settings">Manage sources ${icon('arrow')}</button>
588
+ </div>
589
+ ${!t.records ? '<section class="panel empty-panel"><h2>No data for this period</h2><button class="text-button" data-page="settings">View sources →</button></section>' : ''}
590
+ </div>
591
+ ${receiptPanel(true)}
592
+ </div>`;
593
+ }
594
+ function receiptPage() {
595
+ return /* HTML */ `<div class="page-heading">
596
+ <div><h1>Receipt</h1></div>
597
+ </div>
598
+ <div class="receipt-page-layout">
599
+ ${receiptPanel()}
600
+ <aside class="receipt-aside">
601
+ <span class="eyebrow">API EQUIVALENT</span
602
+ ><strong
603
+ >${summary.total.knownSubtotal !== null ? money(summary.total.apiEquivalent ?? summary.total.knownSubtotal) : summary.total.records ? 'No priced usage' : 'No data'}</strong
604
+ ><span>${periodLabel()}</span>${coverage()}
605
+ <details class="privacy-note">
606
+ <summary>Privacy</summary>
607
+ <p>
608
+ Exports contain provider totals and the selected period. No projects, sessions, paths,
609
+ prompts, or responses.
610
+ </p>
611
+ </details>
612
+ </aside>
613
+ </div>`;
614
+ }
615
+ function billingFields(p , firstRun = false) {
616
+ const b = settings.billing[p],
617
+ plan = subscriptionPlan(p, b.planId);
618
+ return /* HTML */ `<div class="billing-row">
619
+ <label class="provider-setting-name" for="mode-${p}">${name(p)}</label
620
+ ><select
621
+ id="mode-${p}"
622
+ name="mode-${p}"
623
+ data-billing-mode="${p}"
624
+ ${firstRun ? 'required data-first-run="true"' : ''}
625
+ >
626
+ <option value="${firstRun ? '' : 'UNKNOWN'}" ${b.mode === 'UNKNOWN' ? 'selected' : ''}>
627
+ ${firstRun ? 'Choose usage' : 'Not set'}
628
+ </option>
629
+ <option value="SUBSCRIPTION" ${b.mode === 'SUBSCRIPTION' ? 'selected' : ''}>
630
+ Subscription
631
+ </option>
632
+ <option value="API" ${b.mode === 'API' ? 'selected' : ''}>API</option>
633
+ <option value="MIXED" ${b.mode === 'MIXED' ? 'selected' : ''}>Mixed / not sure</option>
634
+ </select>
635
+ <div class="subscription-fields ${b.mode === 'SUBSCRIPTION' ? '' : 'hidden'}" id="amount-${p}">
636
+ <label class="sr-only" for="plan-${p}">${name(p)} subscription plan</label
637
+ ><select id="plan-${p}" name="plan-${p}" data-subscription-plan="${p}">
638
+ <option value="custom" ${!plan ? 'selected' : ''}>Custom amount</option>
639
+ ${subscriptionPlans
640
+ .filter((plan) => plan.provider === p)
641
+ .map(
642
+ (option) =>
643
+ /* HTML */ `<option value="${option.id}" ${plan?.id === option.id ? 'selected' : ''}>
644
+ ${esc(subscriptionPlanLabel(option))}
645
+ </option>`,
646
+ )
647
+ .join('')}</select
648
+ ><label class="amount-field"
649
+ ><span class="sr-only">${name(p)} monthly subscription in USD</span><span>$</span
650
+ ><input
651
+ name="monthly-${p}"
652
+ type="number"
653
+ min="0"
654
+ max="1000000"
655
+ step="0.01"
656
+ value="${plan ? plan.monthly.toFixed(2) : (b.monthly ?? '')}"
657
+ ${plan ? 'readonly' : ''}
658
+ ${firstRun && b.mode === 'SUBSCRIPTION' ? 'required' : ''}
659
+ placeholder="Amount"
660
+ /><span>/ month</span></label
661
+ >
662
+ </div>
663
+ </div>`;
664
+ }
665
+ function settingsPage() {
666
+ return /* HTML */ `<div class="page-heading">
667
+ <div><h1>Settings</h1></div>
668
+ </div>
669
+ <div class="settings-layout">
670
+ <form id="settings-form">
671
+ <section class="panel settings-section">
672
+ <h2>Sources</h2>
673
+ ${summary.providers
674
+ .map((p) => {
675
+ const s = sources.find((s) => s.provider === p.provider);
676
+ return /* HTML */ `<div class="source-row">
677
+ <div>
678
+ <strong>${name(p.provider)}</strong
679
+ ><span class="source-state"
680
+ >${p.status === 'USAGE_FOUND' ? 'Usage found' : p.detected ? 'No usage in selected period' : 'Not detected'}</span
681
+ >
682
+ </div>
683
+ <label class="include-check"
684
+ ><input
685
+ type="checkbox"
686
+ name="include-${p.provider}"
687
+ ${settings.include[p.provider] ? 'checked' : ''}
688
+ />Always show</label
689
+ >
690
+ <details>
691
+ <summary>Details</summary>
692
+ <dl>
693
+ <div>
694
+ <dt>Detection</dt>
695
+ <dd>${p.detected ? 'Detected' : 'Not detected'}</dd>
696
+ </div>
697
+ <div>
698
+ <dt>Imported events</dt>
699
+ <dd>${p.importedRecords.toLocaleString()}</dd>
700
+ </div>
701
+ <div>
702
+ <dt>Last scan</dt>
703
+ <dd>${lastScan ? new Date(lastScan).toLocaleString() : 'Not scanned'}</dd>
704
+ </div>
705
+ <div>
706
+ <dt>Source files</dt>
707
+ <dd>${s?.files ?? 0}</dd>
708
+ </div>
709
+ <div>
710
+ <dt>Available history</dt>
711
+ <dd>
712
+ ${p.first ? p.first.slice(0, 10) + ' → ' + p.last?.slice(0, 10) : 'No data'}
713
+ </dd>
714
+ </div>
715
+ <div>
716
+ <dt>Read errors</dt>
717
+ <dd>${s?.errors ?? 0}</dd>
718
+ </div>
719
+ <div>
720
+ <dt>Malformed / skipped</dt>
721
+ <dd>${(s?.diagnostics.malformed ?? 0) + (s?.diagnostics.oversize ?? 0)}</dd>
722
+ </div>
723
+ <div>
724
+ <dt>Incomplete final line</dt>
725
+ <dd>${s?.diagnostics.partialTail ? 'Yes' : 'No'}</dd>
726
+ </div>
727
+ </dl>
728
+ </details>
729
+ </div>`;
730
+ })
731
+ .join('')}${refreshButton()}
732
+ </section>
733
+ <section class="panel settings-section">
734
+ <h2>Billing</h2>
735
+ ${(['codex', 'claude'] )
736
+ .filter((p) => sources.find((s) => s.provider === p)?.detected)
737
+ .map((p) => billingFields(p))
738
+ .join('')}
739
+ <details class="compact-details">
740
+ <summary>Details</summary>
741
+ <p>
742
+ Plans verified ${subscriptionVerified}.
743
+ ${subscriptionPlans
744
+ .filter((p, i, list) => list.findIndex((q) => q.source === p.source) === i)
745
+ .map(
746
+ (p) =>
747
+ /* HTML */ `<a href="${esc(p.source)}" target="_blank" rel="noreferrer"
748
+ >${p.provider === 'codex' ? 'ChatGPT' : 'Claude'} ↗</a
749
+ >`,
750
+ )
751
+ .join(' · ')}.
752
+ Annual plans use the exact annual price divided by 12; team plans are per seat.
753
+ Enterprise / Edu: custom amount.
754
+ </p>
755
+ <p>
756
+ These are your declarations, not inferred billing history. Amounts are in USD.
757
+ Comparisons use the full monthly price for full calendar months only. API and mixed
758
+ usage have no subscription comparison.
759
+ </p>
760
+ </details>
761
+ </section>
762
+ <section class="panel settings-section">
763
+ <h2>Currency</h2>
764
+ <div class="setting-line"><span>API prices & subscription</span><strong>USD</strong></div>
765
+ </section>
766
+ <section class="panel settings-section">
767
+ <h2>Privacy / data</h2>
768
+ <div class="privacy-tags">
769
+ <span>${icon('check')}Local only</span><span>${icon('check')}Read-only sources</span
770
+ ><span>${icon('check')}No telemetry</span>
771
+ </div>
772
+ <details>
773
+ <summary>Data access</summary>
774
+ <p>
775
+ Only approved Codex and Claude JSONL session files are streamed. Only normalized usage
776
+ metadata is stored. Credentials, project contents and conversation content are not
777
+ stored.
778
+ </p>
779
+ <code
780
+ >~/.codex/sessions/**/*.jsonl<br />~/.codex/archived_sessions/*.jsonl<br />~/.claude/projects/**/*.jsonl</code
781
+ >
782
+ </details>
783
+ </section>
784
+ <div class="form-actions"><button class="button" type="submit">Save changes</button></div>
785
+ </form>
786
+ </div>`;
787
+ }
788
+ function onboarding() {
789
+ const found = summary.providers.filter((p) => p.first),
790
+ billable = summary.providers.filter((p) => p.importedRecords > 0);
791
+ return /* HTML */ `<dialog id="onboarding" aria-labelledby="onboarding-title">
792
+ <span class="brand-mark">${productMark}</span
793
+ ><span class="eyebrow">${esc(product.name.toUpperCase())} / FIRST RUN</span>
794
+ <h2 id="onboarding-title">
795
+ ${onboardingStage === 'found' ? (found.length ? 'Local usage found' : 'No local usage found') : 'How did you use it?'}
796
+ </h2>
797
+ ${
798
+ onboardingStage === 'found'
799
+ ? /* HTML */ `<div class="found-sources">
800
+ ${summary.providers.map((p) => /* HTML */ `<div><strong>${name(p.provider)}</strong><span>${p.first ? `${Math.max(1, Math.ceil((Date.parse(p.last ) - Date.parse(p.first)) / 864e5))} days ${icon('check')}` : p.detected ? 'No data found' : 'Not detected'}</span></div>`).join('')}
801
+ </div>
802
+ <button class="button" id="onboarding-continue">Continue ${icon('arrow')}</button>`
803
+ : /* HTML */ `<form id="onboarding-billing">
804
+ ${billable
805
+ .map(
806
+ (p) =>
807
+ /* HTML */ `<h3>How did you use ${name(p.provider)}?</h3>
808
+ ${billingFields(p.provider, true)}`,
809
+ )
810
+ .join('')}<button class="button" type="submit">
811
+ Open overview ${icon('arrow')}
812
+ </button>
813
+ </form>
814
+ <button class="text-button onboarding-skip" id="onboarding-skip">Set up later</button>`
815
+ }
816
+ </dialog>`;
817
+ }
818
+ function render() {
819
+ app.innerHTML = `${demo ? '<div class="demo-banner">DEMO / FIXTURE DATA — no local sources accessed</div>' : ''}${
820
+ !settings.onboarded
821
+ ? onboarding()
822
+ : /* HTML */ `<div class="app-shell">
823
+ ${navigation()}
824
+ <main class="app-main">
825
+ ${page === 'overview' ? overview() : page === 'receipt' ? receiptPage() : settingsPage()}
826
+ </main>
827
+ <footer class="app-footer">
828
+ <span>${esc(product.name)} <span class="version">0.1</span></span
829
+ ><span>LOCAL / READ-ONLY</span>
830
+ </footer>
831
+ </div>`
832
+ }`;
833
+ const canvas = document.querySelector ('#receipt-canvas');
834
+ if (canvas) drawReceipt(canvas, displayedReceipt, settings.receiptTheme ?? 'dark');
835
+ const dialog = document.querySelector ('#onboarding');
836
+ if (dialog) {
837
+ dialog.addEventListener('cancel', (event) => event.preventDefault());
838
+ dialog.showModal();
839
+ }
840
+ bind();
841
+ }
842
+ function readBilling(form ) {
843
+ const data = new FormData(form);
844
+ for (const p of ['codex', 'claude'] ) {
845
+ const mode = data.get(`mode-${p}`);
846
+ if (mode) {
847
+ const amount = data.get(`monthly-${p}`),
848
+ plan = subscriptionPlan(p, String(data.get(`plan-${p}`)));
849
+ settings.billing[p] = {
850
+ mode: mode ,
851
+ monthly:
852
+ mode === 'SUBSCRIPTION'
853
+ ? (plan?.monthly ?? (amount !== '' && amount !== null ? Number(amount) : null))
854
+ : null,
855
+ ...(mode === 'SUBSCRIPTION' ? { planId: plan?.id ?? null } : {}),
856
+ };
857
+ }
858
+ }
859
+ }
860
+ async function save() {
861
+ await api('/api/settings', 'POST', settings);
862
+ await load();
863
+ render();
864
+ }
865
+ async function choosePeriod(
866
+ next ,
867
+ nextFrom = from,
868
+ nextTo = to,
869
+ nextMonth = selectedMonth,
870
+ nextAnchor = '',
871
+ focusSelector ,
872
+ ) {
873
+ if (periodLoading || busy) return;
874
+ const previous = { period, from, to, selectedMonth, anchor };
875
+ periodLoading = true;
876
+ period = next;
877
+ from = nextFrom;
878
+ to = nextTo;
879
+ selectedMonth = nextMonth;
880
+ anchor = nextAnchor;
881
+ const controls = document.querySelectorAll (
882
+ '[data-period],[data-period-step],#period-picker button',
883
+ );
884
+ controls.forEach((button) => (button.disabled = true));
885
+ try {
886
+ if (await load()) {
887
+ render();
888
+ document
889
+ .querySelector (focusSelector ?? `[data-period="${period}"]`)
890
+ ?.focus({ preventScroll: true });
891
+ }
892
+ } catch {
893
+ ({ period, from, to, selectedMonth, anchor } = previous);
894
+ toast('Choose a valid date range');
895
+ } finally {
896
+ periodLoading = false;
897
+ document
898
+ .querySelectorAll ('[data-period],[data-period-step],#period-picker button')
899
+ .forEach(
900
+ (button) =>
901
+ (button.disabled =
902
+ busy ||
903
+ !!(button.dataset.periodStep && !adjacentPeriod(Number(button.dataset.periodStep)))),
904
+ );
905
+ }
906
+ }
907
+ function bind() {
908
+ document.querySelectorAll ('[data-page]').forEach((b) =>
909
+ b.addEventListener('click', () => {
910
+ page = b.dataset.page ;
911
+ history.replaceState(null, '', page === 'overview' ? '/' : `/${page}`);
912
+ render();
913
+ window.scrollTo(0, 0);
914
+ }),
915
+ );
916
+ const picker = document.querySelector ('#period-picker');
917
+ picker?.addEventListener('toggle', () => {
918
+ document
919
+ .querySelectorAll ('[aria-controls="period-picker"]')
920
+ .forEach((button) =>
921
+ button.setAttribute(
922
+ 'aria-expanded',
923
+ String(
924
+ picker.matches(':popover-open') && picker.dataset.period === button.dataset.period,
925
+ ),
926
+ ),
927
+ );
928
+ });
929
+ document.querySelectorAll ('[data-period]').forEach((button) =>
930
+ button.addEventListener('click', () => {
931
+ if (busy || periodLoading) return;
932
+ const next = button.dataset.period ;
933
+ if (next !== 'month' && next !== 'custom') {
934
+ void choosePeriod(next);
935
+ return;
936
+ }
937
+ if (!picker) return;
938
+ picker.dataset.period = next;
939
+ picker.querySelector ('#month-range') .hidden = next !== 'month';
940
+ picker.querySelector ('#custom-range') .hidden = next !== 'custom';
941
+ if (!picker.matches(':popover-open')) picker.showPopover();
942
+ const bounds = button.getBoundingClientRect();
943
+ picker.style.left =
944
+ Math.max(
945
+ 16,
946
+ Math.min(innerWidth - picker.offsetWidth - 16, bounds.right - picker.offsetWidth),
947
+ ) + 'px';
948
+ picker.style.top = bounds.bottom + 8 + 'px';
949
+ document
950
+ .querySelectorAll ('[aria-controls="period-picker"]')
951
+ .forEach((control) => control.setAttribute('aria-expanded', String(control === button)));
952
+ picker.querySelector ('form:not([hidden]) input')?.focus();
953
+ }),
954
+ );
955
+ document.querySelectorAll ('[data-period-step]').forEach((button) =>
956
+ button.addEventListener('click', () => {
957
+ if (busy || periodLoading) return;
958
+ const direction = Number(button.dataset.periodStep),
959
+ next = adjacentPeriod(direction);
960
+ if (!next) return;
961
+ void choosePeriod(
962
+ period,
963
+ next.from,
964
+ next.to,
965
+ next.month,
966
+ period === '7d' || period === '30d' ? next.anchor : '',
967
+ `[data-period-step="${direction}"]`,
968
+ );
969
+ }),
970
+ );
51
971
  bindChart();
52
- document.querySelectorAll ('[data-receipt-theme]').forEach(button=>button.addEventListener('click',async()=>{
53
- if(themeSaving)return;const next=button.dataset.receiptTheme;if(next!=='dark'&&next!=='light')return;const previous=settings.receiptTheme;if((previous??'dark')===next)return;
54
- themeSaving=true;settings.receiptTheme=next;
55
- const update=()=>{const canvas=document.querySelector ('#receipt-canvas');if(canvas)drawReceipt(canvas,displayedReceipt,settings.receiptTheme??'dark');document.querySelectorAll ('[data-receipt-theme]').forEach(control=>{control.setAttribute('aria-pressed',String(control.dataset.receiptTheme===(settings.receiptTheme??'dark')));control.disabled=themeSaving;});};
56
- update();try{await api('/api/settings','POST',settings);}catch{settings.receiptTheme=previous;toast('Unable to save receipt color');}finally{themeSaving=false;update();}
57
- }));
58
- document.querySelector ('#custom-range')?.addEventListener('submit',async e=>{e.preventDefault();const data=new FormData(e.currentTarget );from=String(data.get('from'));to=String(data.get('to'));try{if(await load())render();}catch{toast('Choose a valid date range');}});
59
- document.querySelectorAll ('[data-billing-mode]').forEach(select=>select.addEventListener('change',()=>{document.querySelector(`#amount-${select.dataset.billingMode}`)?.classList.toggle('hidden',select.value!=='SUBSCRIPTION');const amount=document.querySelector (`[name="monthly-${select.dataset.billingMode}"]`);if(amount)amount.required=select.dataset.firstRun==='true'&&select.value==='SUBSCRIPTION';}));
60
- document.querySelectorAll ('[data-subscription-plan]').forEach(select=>select.addEventListener('change',()=>{const p=select.dataset.subscriptionPlan ,plan=subscriptionPlan(p,select.value),input=document.querySelector (`[name="monthly-${p}"]`) ;input.readOnly=!!plan;if(plan)input.value=plan.monthly.toFixed(2);}));
61
- document.querySelector ('#settings-form')?.addEventListener('submit',async e=>{e.preventDefault();const form=e.currentTarget ;readBilling(form);const data=new FormData(form);for(const p of ['codex','claude'] )settings.include[p]=data.has(`include-${p}`);try{await save();toast('Saved locally');}catch{toast('Unable to save settings');}});
62
- document.querySelector('#export-png')?.addEventListener('click',async()=>{try{await exportReceipt(document.querySelector ('#receipt-canvas') );toast('Receipt exported');}catch{toast('Unable to export PNG');}});
63
- document.querySelector('#rescan')?.addEventListener('click',async()=>{if(busy)return;busy=true;updateRefreshButton();try{await api('/api/rescan','POST');const poll=async()=>{try{const b=await api ('/api/bootstrap');if(b.scanning){setTimeout(()=>void poll(),1000);return;}sources=b.sources;lastScan=b.lastScan;busy=false;await load();render();toast('Sources updated');}catch{busy=false;updateRefreshButton();toast('Unable to scan');}};setTimeout(()=>void poll(),1000);}catch{busy=false;updateRefreshButton();toast('Unable to scan');}});
64
- document.querySelector('#onboarding-continue')?.addEventListener('click',async()=>{if(!summary.providers.some(p=>p.importedRecords>0)){settings.onboarded=true;try{await save();}catch{settings.onboarded=false;toast('Unable to save');}return;}onboardingStage='billing';render();});document.querySelector('#onboarding-skip')?.addEventListener('click',async()=>{settings.onboarded=true;try{await save();}catch{settings.onboarded=false;toast('Unable to save');}});document.querySelector ('#onboarding-billing')?.addEventListener('submit',async e=>{e.preventDefault();readBilling(e.currentTarget );settings.onboarded=true;try{await save();}catch{settings.onboarded=false;toast('Unable to save');}});
65
- }
66
- async function boot(){try{const b=await api ('/api/bootstrap');settings=b.settings;sources=b.sources;token=b.token;demo=b.demo;lastScan=b.lastScan;await Promise.all([load(),receiptFonts(),document.fonts.load('14px Inter')]);render();}catch{app.innerHTML=`<main class="loading"><h1>${esc(product.name)}</h1><p>Unable to load local usage.</p><button class="button" id="retry">Retry</button></main>`;document.querySelector('#retry')?.addEventListener('click',()=>void boot());}}
972
+ document.querySelectorAll ('[data-receipt-theme]').forEach((button) =>
973
+ button.addEventListener('click', async () => {
974
+ if (themeSaving) return;
975
+ const next = button.dataset.receiptTheme;
976
+ if (next !== 'dark' && next !== 'light') return;
977
+ const previous = settings.receiptTheme;
978
+ if ((previous ?? 'dark') === next) return;
979
+ themeSaving = true;
980
+ settings.receiptTheme = next;
981
+ const update = () => {
982
+ const canvas = document.querySelector ('#receipt-canvas');
983
+ if (canvas) drawReceipt(canvas, displayedReceipt, settings.receiptTheme ?? 'dark');
984
+ document.querySelectorAll ('[data-receipt-theme]').forEach((control) => {
985
+ control.setAttribute(
986
+ 'aria-pressed',
987
+ String(control.dataset.receiptTheme === (settings.receiptTheme ?? 'dark')),
988
+ );
989
+ control.disabled = themeSaving;
990
+ });
991
+ };
992
+ update();
993
+ try {
994
+ await api('/api/settings', 'POST', settings);
995
+ } catch {
996
+ settings.receiptTheme = previous;
997
+ toast('Unable to save receipt color');
998
+ } finally {
999
+ themeSaving = false;
1000
+ update();
1001
+ }
1002
+ }),
1003
+ );
1004
+ document.querySelector ('#custom-range')?.addEventListener('submit', (e) => {
1005
+ e.preventDefault();
1006
+ const data = new FormData(e.currentTarget );
1007
+ void choosePeriod('custom', String(data.get('from')), String(data.get('to')));
1008
+ });
1009
+ document.querySelector ('#month-range')?.addEventListener('submit', (e) => {
1010
+ e.preventDefault();
1011
+ const data = new FormData(e.currentTarget );
1012
+ void choosePeriod('month', from, to, String(data.get('month')));
1013
+ });
1014
+ document.querySelectorAll ('[data-billing-mode]').forEach((select) =>
1015
+ select.addEventListener('change', () => {
1016
+ document
1017
+ .querySelector(`#amount-${select.dataset.billingMode}`)
1018
+ ?.classList.toggle('hidden', select.value !== 'SUBSCRIPTION');
1019
+ const amount = document.querySelector (
1020
+ `[name="monthly-${select.dataset.billingMode}"]`,
1021
+ );
1022
+ if (amount)
1023
+ amount.required = select.dataset.firstRun === 'true' && select.value === 'SUBSCRIPTION';
1024
+ }),
1025
+ );
1026
+ document.querySelectorAll ('[data-subscription-plan]').forEach((select) =>
1027
+ select.addEventListener('change', () => {
1028
+ const p = select.dataset.subscriptionPlan ,
1029
+ plan = subscriptionPlan(p, select.value),
1030
+ input = document.querySelector (`[name="monthly-${p}"]`) ;
1031
+ input.readOnly = !!plan;
1032
+ if (plan) input.value = plan.monthly.toFixed(2);
1033
+ }),
1034
+ );
1035
+ document
1036
+ .querySelector ('#settings-form')
1037
+ ?.addEventListener('submit', async (e) => {
1038
+ e.preventDefault();
1039
+ const form = e.currentTarget ;
1040
+ readBilling(form);
1041
+ const data = new FormData(form);
1042
+ for (const p of ['codex', 'claude'] )
1043
+ settings.include[p] = data.has(`include-${p}`);
1044
+ try {
1045
+ await save();
1046
+ toast('Saved locally');
1047
+ } catch {
1048
+ toast('Unable to save settings');
1049
+ }
1050
+ });
1051
+ document.querySelector('#export-png')?.addEventListener('click', async () => {
1052
+ try {
1053
+ await exportReceipt(document.querySelector ('#receipt-canvas') );
1054
+ toast('Receipt exported');
1055
+ } catch {
1056
+ toast('Unable to export PNG');
1057
+ }
1058
+ });
1059
+ document.querySelector('#rescan')?.addEventListener('click', async () => {
1060
+ if (busy) return;
1061
+ busy = true;
1062
+ updateRefreshButton();
1063
+ try {
1064
+ await api('/api/rescan', 'POST');
1065
+ const poll = async () => {
1066
+ try {
1067
+ const b = await api ('/api/bootstrap');
1068
+ if (b.scanning) {
1069
+ setTimeout(() => void poll(), 1000);
1070
+ return;
1071
+ }
1072
+ sources = b.sources;
1073
+ lastScan = b.lastScan;
1074
+ busy = false;
1075
+ await load();
1076
+ render();
1077
+ toast('Sources updated');
1078
+ } catch {
1079
+ busy = false;
1080
+ updateRefreshButton();
1081
+ toast('Unable to scan');
1082
+ }
1083
+ };
1084
+ setTimeout(() => void poll(), 1000);
1085
+ } catch {
1086
+ busy = false;
1087
+ updateRefreshButton();
1088
+ toast('Unable to scan');
1089
+ }
1090
+ });
1091
+ document.querySelector('#onboarding-continue')?.addEventListener('click', async () => {
1092
+ if (!summary.providers.some((p) => p.importedRecords > 0)) {
1093
+ settings.onboarded = true;
1094
+ try {
1095
+ await save();
1096
+ } catch {
1097
+ settings.onboarded = false;
1098
+ toast('Unable to save');
1099
+ }
1100
+ return;
1101
+ }
1102
+ onboardingStage = 'billing';
1103
+ render();
1104
+ });
1105
+ document.querySelector('#onboarding-skip')?.addEventListener('click', async () => {
1106
+ settings.onboarded = true;
1107
+ try {
1108
+ await save();
1109
+ } catch {
1110
+ settings.onboarded = false;
1111
+ toast('Unable to save');
1112
+ }
1113
+ });
1114
+ document
1115
+ .querySelector ('#onboarding-billing')
1116
+ ?.addEventListener('submit', async (e) => {
1117
+ e.preventDefault();
1118
+ readBilling(e.currentTarget );
1119
+ settings.onboarded = true;
1120
+ try {
1121
+ await save();
1122
+ } catch {
1123
+ settings.onboarded = false;
1124
+ toast('Unable to save');
1125
+ }
1126
+ });
1127
+ }
1128
+ async function boot() {
1129
+ try {
1130
+ const b = await api ('/api/bootstrap');
1131
+ settings = b.settings;
1132
+ sources = b.sources;
1133
+ token = b.token;
1134
+ demo = b.demo;
1135
+ lastScan = b.lastScan;
1136
+ await Promise.all([load(), receiptFonts(), document.fonts.load('14px Inter')]);
1137
+ render();
1138
+ } catch {
1139
+ app.innerHTML = /* HTML */ `<main class="loading">
1140
+ <h1>${esc(product.name)}</h1>
1141
+ <p>Unable to load local usage.</p>
1142
+ <button class="button" id="retry">Retry</button>
1143
+ </main>`;
1144
+ document.querySelector('#retry')?.addEventListener('click', () => void boot());
1145
+ }
1146
+ }
67
1147
  void boot();