diff --git a/app/main.py b/app/main.py index 3319131..df6897e 100644 --- a/app/main.py +++ b/app/main.py @@ -240,6 +240,15 @@ PAGE_HTML = """ .adjust input { width: 64px; padding: 4px 6px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; } .adjust button { cursor: pointer; border: none; border-radius: 6px; background: var(--green-deep); color: #fff; font-size: 12px; font-weight: 600; padding: 4px 10px; } .adjust button:hover { background: #4e7a4d; } + .tabs { display: flex; gap: 8px; margin: 28px 0 12px; align-items: center; } + .tabs h2 { font-size: 17px; font-weight: 700; margin-right: 8px; } + .tab-btn { cursor: pointer; border: 1px solid var(--border); background: #fff; color: var(--muted); border-radius: 8px; font-size: 13px; font-weight: 600; padding: 6px 14px; } + .tab-btn.active { background: var(--green-deep); color: #fff; border-color: var(--green-deep); } + .range-btn { cursor: pointer; border: 1px solid var(--border); background: #fff; color: var(--muted); border-radius: 8px; font-size: 12px; font-weight: 600; padding: 5px 12px; margin-left: auto; } + .range-btn.active { background: var(--green-mid); color: #fff; border-color: var(--green-mid); } + th.sortable { cursor: pointer; user-select: none; } + th.sortable:hover { color: var(--green-deep); } + th .arrow { font-size: 10px; margin-left: 3px; opacity: 0.7; } @@ -259,20 +268,37 @@ PAGE_HTML = """
- +
+

Members

+ + + + +
+ +
- + + + + + + + +
MemberStatusActivatedBalanceSpendRemainingResetAdjustMemberStatusActivatedBalanceSpendRemainingResetAdjust
Loading…
-

Model usage

- +
- + + + + @@ -293,24 +319,14 @@ function fmt(n) { return '$' + (Number(n) || 0).toFixed(2); } async function loadModels() { try { - const r = await fetch('/api/model-usage'); + const r = await fetch('/api/model-usage?days=' + rangeDays); if (r.status === 403) return; const d = await r.json(); - if (d.error) { $('model-rows').innerHTML = ''; return; } - const models = d.models || []; - if (!models.length) { - $('model-rows').innerHTML = ''; - return; - } - $('model-rows').innerHTML = models.map(m => - '' + - '' + - '' + - '' + - '' + - '' - ).join(''); + if (d.error) { modelsData = []; $('model-rows').innerHTML = ''; return; } + modelsData = d.models || []; + if (currentView === 'models') renderModels(); } catch (e) { + modelsData = []; $('model-rows').innerHTML = ''; } } @@ -323,14 +339,84 @@ async function load() { $('tot-members').textContent = d.totals.members; $('tot-active').textContent = d.totals.active; $('tot-spend').textContent = fmt(d.totals.total_spend); - render(d.members); + membersData = d.members || []; + if (currentView === 'members') renderMembers(); } catch (e) { flash('Could not load: ' + e.message, false); } } -function render(members) { - $('rows').innerHTML = members.map(m => { +// ---- shared state ---- +let membersData = []; +let modelsData = []; +let currentView = 'members'; +let memberSort = { key: 'email', dir: 1 }; +let modelSort = { key: 'spend', dir: -1 }; +let rangeDays = 0; // 0 = all time + +function arrowFor(key, sortState) { + if (sortState.key !== key) return ''; + return sortState.dir === 1 ? '▲' : '▼'; +} + +function clearArrows(table, sortState) { + document.querySelectorAll('#table-' + table + ' .arrow').forEach(el => { el.textContent = ''; }); + document.querySelectorAll('#table-' + table + ' th').forEach(th => { + const span = th.querySelector('.arrow'); + if (!span) return; + const key = th.getAttribute('onclick')?.match(/'([^']+)'/)?.[1]; + if (key) span.textContent = arrowFor(key, sortState); + }); +} + +function switchView(view) { + currentView = view; + $('table-members').style.display = view === 'members' ? '' : 'none'; + $('table-models').style.display = view === 'models' ? '' : 'none'; + $('tab-members').className = 'tab-btn' + (view === 'members' ? ' active' : ''); + $('tab-models').className = 'tab-btn' + (view === 'models' ? ' active' : ''); + $('view-title').textContent = view === 'members' ? 'Members' : 'Model usage'; + // Range buttons only apply to the models view (spend logs are model-level). + const showRange = view === 'models'; + $('range-30').style.display = showRange ? '' : 'none'; + $('range-all').style.display = showRange ? '' : 'none'; + render(); +} + +function setRange(days) { + rangeDays = days; + $('range-30').className = 'range-btn' + (days === 30 ? ' active' : ''); + $('range-all').className = 'range-btn' + (days === 0 ? ' active' : ''); + loadModels(); +} + +function sortMembers(key) { + memberSort = { key, dir: memberSort.key === key ? -memberSort.dir : 1 }; + clearArrows('members', memberSort); + render(); +} + +function sortModels(key) { + modelSort = { key, dir: modelSort.key === key ? -modelSort.dir : -1 }; + clearArrows('models', modelSort); + render(); +} + +function render() { + if (currentView === 'members') renderMembers(); + else renderModels(); +} + +function renderMembers() { + const rows = [...membersData].sort((a, b) => { + let va = a[memberSort.key], vb = b[memberSort.key]; + if (memberSort.key === 'active' || memberSort.key === 'activated') { va = va ? 1 : 0; vb = vb ? 1 : 0; } + if (typeof va === 'string' || typeof vb === 'string') { + return String(va ?? '').localeCompare(String(vb ?? '')) * memberSort.dir; + } + return ((va ?? 0) - (vb ?? 0)) * memberSort.dir; + }); + $('rows').innerHTML = rows.map(m => { const reset = m.reset_at ? m.reset_at.slice(0,10) : '—'; const pill = m.active ? 'active' : 'inactive'; const act = m.activated @@ -350,6 +436,30 @@ function render(members) { '' + ''; }).join(''); + clearArrows('members', memberSort); +} + +function renderModels() { + const rows = [...modelsData].sort((a, b) => { + const va = a[modelSort.key], vb = b[modelSort.key]; + if (typeof va === 'string' || typeof vb === 'string') { + return String(va ?? '').localeCompare(String(vb ?? '')) * modelSort.dir; + } + return ((va ?? 0) - (vb ?? 0)) * modelSort.dir; + }); + if (!rows.length) { + $('model-rows').innerHTML = ''; + return; + } + $('model-rows').innerHTML = rows.map(m => + '' + + '' + + '' + + '' + + '' + + '' + ).join(''); + clearArrows('models', modelSort); } function i(email) { return email.replace(/[^a-z0-9]/g, '_'); }