openvibers/openvibe.actor Commits

Actor: the Console shows the last 7 days of spend (#59)

OP OpenVibers committed 30 days ago · de5fc14ef7 · 4 files, +34 −2

Plan T17, Console next step "costs over time": the Today card shows the last 7 days of spend, from the `spend_daily` rows Actor already keeps.

- Bars per day, with today highlighted, and the week's total and task count.
- A visually hidden table carries the same numbers for screen readers.
- New store helper `spendDays(s, requester, days)`: oldest first, a zero for days with nothing.

Tests: `test/console.test.js` checks the week total and that there are seven bars. Checked at 1280 px with a sample week.

Changes

ddiff --git a/public/css/console.css b/public/css/console.css
iindex eeff3b8..4457570 100644
---- a/public/css/console.css
++++ b/public/css/console.css
@@@ -45,6 +45,14 @@
.cn-stat b { color: var(--text-primary, #e6edf7); font-size: 1.25rem; font-variant-numeric: tabular-nums; }
.cn-meter { height: 6px; border-radius: 999px; background: color-mix(in srgb, var(--text-secondary, #96a7c2) 20%, transparent); overflow: hidden; }
.cn-meter span { display: block; height: 100%; border-radius: inherit; background: var(--accent, #3b82f6); }
++.cn-week { margin: .35rem 0 0; }
++.cn-week figcaption { font-size: 13px; color: var(--text-secondary, #96a7c2); }
++.cn-week figcaption b { color: var(--text-primary, #e6edf7); font-variant-numeric: tabular-nums; }
++.cn-bars { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; align-items: end; height: 72px; margin-top: .45rem; }
++.cn-bar { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 3px; height: 100%; }
++.cn-bar i { display: block; width: 100%; max-width: 22px; border-radius: 4px 4px 2px 2px; background: color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); }
++.cn-bar:last-child i { background: var(--accent, #3b82f6); }
++.cn-bar small { font-size: 11px; color: var(--text-secondary, #96a7c2); }
@media (max-width: 60rem) { .cn-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 40rem) {
ddiff --git a/server/http/console-pages.js b/server/http/console-pages.js
iindex 9cca384..2c5dec3 100644
---- a/server/http/console-pages.js
++++ b/server/http/console-pages.js
@@@ -68,6 +68,16 @@ function createConsolePages(ctx, { robots = null, computers = null } = {}) {
<span class="cn-meta">${agent ? html`${agent} · ` : ''}${t.cost_usd == null ? '' : html`${usd(Number(t.cost_usd))} · `}${time(t.created_at)}</span></li>`;
};
++ /** The last seven days of spend as bars (a table for screen readers), tallest day = full height. */
++ function weekChart(days) {
++ const top = Math.max(...days.map((d) => d.usd), 0);
++ const total = days.reduce((n, d) => n + d.usd, 0);
++ const label = (d) => new Date(`${d.day}T00:00:00Z`).toLocaleDateString('en', { weekday: 'short', timeZone: 'UTC' });
++ return html`<figure class="cn-week"><figcaption>Last 7 days: <b>${usd(total)}</b>, ${String(days.reduce((n, d) => n + d.tasks, 0))} tasks</figcaption>
++<div class="cn-bars" aria-hidden="true">${days.map((d) => html`<span class="cn-bar" title="${label(d)}: ${usd(d.usd)}, ${String(d.tasks)} tasks"><i style="height:${String(top > 0 ? Math.max(4, Math.round((d.usd / top) * 100)) : 4)}%"></i><small>${label(d).slice(0, 2)}</small></span>`)}</div>
++<table class="cn-sr"><caption>Spend by day</caption><tbody>${days.map((d) => html`<tr><th scope="row">${d.day}</th><td>${usd(d.usd)}</td><td>${String(d.tasks)} tasks</td></tr>`)}</tbody></table></figure>`;
++ }
++
function computersCard(result) {
const body = !bot.computers.enabled ? html`<p class="cn-empty">Not connected to OpenVibe.Bot on this server.</p>`
: !result.ok ? html`<p class="cn-empty">${result.why}</p>`
@@@ -101,9 +111,10 @@ ${c.waiting_for_approval ? html`<span class="pill pill-running">${String(c.waiti
}
const requester = `user:${req.viewer.subject}`;
const subject = req.viewer.subject;
-- const [tasks, today, asked, agents, pcs, rovers] = await Promise.all([
++ const [tasks, today, week, asked, agents, pcs, rovers] = await Promise.all([
store.listTasks(s, requester, { limit: 30 }),
store.spendToday(s, requester),
++ store.spendDays(s, requester, 7),
approvals.list(s, requester),
personal.list(s, requester),
bot.computers.enabled ? fromBot((o) => bot.computers.mine(subject, o)) : { ok: false },
@@@ -133,6 +144,7 @@ ${nWaiting ? '' : html`<p class="cn-empty">Nothing waits for you.</p>`}
<div class="cn-meter" role="progressbar" aria-label="Free tasks used today" aria-valuemin="0" aria-valuemax="${String(tier.tasksPerDay)}" aria-valuenow="${String(today.tasks)}"><span style="width:${String(pct(today.tasks, tier.tasksPerDay))}%"></span></div>
<p class="cn-stat"><b>${usd(today.usd)}</b> of ${usd(tier.perDayUsd)}</p>
<div class="cn-meter" role="progressbar" aria-label="Spent today" aria-valuemin="0" aria-valuemax="${String(tier.perDayUsd)}" aria-valuenow="${String(today.usd)}"><span style="width:${String(pct(today.usd, tier.perDayUsd))}%"></span></div>
++${weekChart(week)}
<p class="cn-more"><a href="/tasks">Your tasks</a></p></section>
<section class="cn-card cn-wide" aria-labelledby="cn-recent"><h2 id="cn-recent">Recent tasks</h2>
${recent.length ? html`<ul class="cn-tasks">${recent.map(taskRow)}</ul>` : html`<p class="cn-empty">No finished task yet. Give Actor one above.</p>`}
ddiff --git a/server/tasks/store.js b/server/tasks/store.js
iindex 98ee8be..6e8eb6d 100644
---- a/server/tasks/store.js
++++ b/server/tasks/store.js
@@@ -106,6 +106,15 @@ async function spendToday(s, requester) {
return { usd: row ? Number(row.usd) : 0, tasks: row ? Number(row.tasks) : 0 };
}
++/** The last `days` days of a requester's spend, oldest first, each day present (0 when nothing ran). */
++async function spendDays(s, requester, days = 7) {
++ const today = Date.parse(`${dayOf(s.now())}T00:00:00Z`);
++ const keys = Array.from({ length: days }, (_, i) => dayOf(today - (days - 1 - i) * 86_400_000));
++ const rows = await s.db.many('SELECT day, usd, tasks FROM spend_daily WHERE requester = $1 AND day = ANY($2)', [requester, keys]);
++ const by = new Map(rows.map((r) => [String(r.day).slice(0, 10), r]));
++ return keys.map((day) => ({ day, usd: by.has(day) ? Number(by.get(day).usd) : 0, tasks: by.has(day) ? Number(by.get(day).tasks) : 0 }));
++}
++
/** At boot: a task a stopped process left open did not finish, and is not re-run behind the person's back. */
async function failInterrupted(s) {
const rows = await s.db.many(
@@@ -151,4 +160,4 @@ function toWire(row, { baseUrl }) {
return t;
}
--module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, addSpend, spendToday, lockSpendToday, failInterrupted, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS };
++module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS };
ddiff --git a/test/console.test.js b/test/console.test.js
iindex 206445c..cb0b051 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -55,6 +55,7 @@ const approvals = require('../server/agents/approvals');
const running = await task(owner, 'Check the certificate of example.com', 'running');
await task(owner, 'Summarise the Node.js release notes', 'succeeded');
await task(`user:${bo.subject}`, 'Bo\'s private task', 'running');
++ await store.addSpend(t.ctx.s, owner, 0.0012);
await approvals.request(t.ctx.s, { owner, kind: 'codes.task', summary: 'Fix the failing build in ada/site', payload: { task: 'fix it' } });
await personal.create(t.ctx.s, t.config, owner, { name: 'Morning briefing', instructions: 'Summarise what shipped.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 1, schedule: 'daily', schedule_at: '08:00', status: 'active' });
const r = await t.get('/console', { as: ada });
@@@ -73,6 +74,8 @@ const approvals = require('../server/agents/approvals');
assert.match(h, /desk-pc[\s\S]*Ask first · terminal, files/);
assert.match(h, /Desk rover[\s\S]*battery 80%/);
assert.match(h, /openvibe\.services\/projects/);
++ assert.match(h, /Last 7 days: <b>\$0\.0012<\/b>, 2 tasks/, 'the week: what the two tasks spent today');
++ assert.strictEqual((h.match(/class="cn-bar"/g) || []).length, 7);
assert.deepStrictEqual(asked.filter((a) => a[1] !== ada.subject), [], 'Bot is asked as the person only');
});