Skip to content

Commit 3719d2e

Browse files
authored
Merge pull request #198 from httpsphl/feat/plan-share
feat(usage): each bot's share of the weekly plan
2 parents f8f02d8 + 03ef3d3 commit 3719d2e

29 files changed

Lines changed: 571 additions & 43 deletions

File tree

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
1-
// Tokens each bot used over a period (spec 8.7), in the usage dialog: the
2-
// text read plus the replies written. Each bot shows its crew, as two crews can have
3-
// bots with the same name.
1+
// What each bot used over a period (spec 8.7), in the usage dialog: about
2+
// how much of the weekly plan, once Botloft has learned it from how the
3+
// plan rises while the bots work, and the tokens read and written. Each
4+
// bot shows its crew, as two crews can have bots with the same name.
45

56
import { useEffect, useState } from "react";
67
import { useT } from "../../i18n";
78
import { errorText } from "../../lib/api";
8-
import { tokens, usedTokens } from "../../lib/format";
9+
import { planPercent, tokens, usedTokens } from "../../lib/format";
910
import type { BotTokens } from "../../lib/protocol.gen";
1011
import { useApi } from "../../store/context";
1112
import { Callout } from "../../ui/Callout";
1213
import { Choices } from "../../ui/Choices";
1314
import { BotAvatar } from "../bots/BotAvatar";
1415

15-
type Period = "today" | "week" | "month" | "all";
16+
type Period = "hour" | "today" | "week" | "month" | "all";
1617

17-
const DAY_MS = 86_400_000;
18+
const HOUR_MS = 3_600_000;
19+
const DAY_MS = 24 * HOUR_MS;
1820

1921
/** When `period` starts, in Unix ms. */
2022
export function periodStart(period: Period, now = Date.now()): number {
2123
switch (period) {
24+
case "hour":
25+
return now - HOUR_MS;
2226
case "today":
2327
return new Date(now).setHours(0, 0, 0, 0);
2428
case "week":
@@ -30,6 +34,11 @@ export function periodStart(period: Period, now = Date.now()): number {
3034
}
3135
}
3236

37+
/** How much a bot weighs in the list: its plan share, else its tokens. */
38+
function weight(bot: BotTokens): number {
39+
return bot.planShare ?? usedTokens(bot.tokens);
40+
}
41+
3342
export function TokensByBot() {
3443
const u = useT().account.usage.tokens;
3544
const api = useApi();
@@ -41,19 +50,20 @@ export function TokensByBot() {
4150
let alive = true;
4251
setFailed(null);
4352
api.call("usage.tokens", { since: periodStart(period) }).then(
44-
(list) => alive && setBots(list),
53+
(list) => alive && setBots([...list].sort((a, b) => weight(b) - weight(a))),
4554
(error) => alive && setFailed(errorText(error)),
4655
);
4756
return () => {
4857
alive = false;
4958
};
5059
}, [api, period]);
5160

52-
const periods = (["today", "week", "month", "all"] as const).map((value) => ({
61+
const periods = (["hour", "today", "week", "month", "all"] as const).map((value) => ({
5362
value,
5463
label: u.periods[value],
5564
}));
56-
const most = Math.max(1, ...(bots ?? []).map((bot) => usedTokens(bot.tokens)));
65+
const most = Math.max(Number.MIN_VALUE, ...(bots ?? []).map(weight));
66+
const learned = (bots ?? []).some((bot) => bot.planShare !== null);
5767

5868
return (
5969
<section className="flex flex-col gap-3 border-line border-t pt-4">
@@ -67,12 +77,15 @@ export function TokensByBot() {
6777
) : bots === null ? null : bots.length === 0 ? (
6878
<p className="text-muted">{u.empty}</p>
6979
) : (
70-
<ul aria-label={u.title} className="flex flex-col gap-3">
71-
{bots.map((bot) => (
72-
<Row key={bot.botId} bot={bot} most={most} />
73-
))}
74-
{bots.length > 1 && <Total bots={bots} />}
75-
</ul>
80+
<>
81+
<ul aria-label={u.title} className="flex flex-col gap-3">
82+
{bots.map((bot) => (
83+
<Row key={bot.botId} bot={bot} most={most} />
84+
))}
85+
{bots.length > 1 && <Total bots={bots} />}
86+
</ul>
87+
<p className="text-muted text-xs leading-relaxed">{learned ? u.estimate : u.learning}</p>
88+
</>
7689
)}
7790
</section>
7891
);
@@ -81,6 +94,7 @@ export function TokensByBot() {
8194
function Row({ bot, most }: { bot: BotTokens; most: number }) {
8295
const u = useT().account.usage.tokens;
8396
const used = usedTokens(bot.tokens);
97+
const share = bot.planShare;
8498
return (
8599
<li className="flex items-center gap-3">
86100
<BotAvatar color={bot.color} size={24} />
@@ -90,15 +104,20 @@ function Row({ bot, most }: { bot: BotTokens; most: number }) {
90104
{bot.name}
91105
{bot.archived && <span className="ml-1.5 text-muted text-xs">{u.archived}</span>}
92106
</span>
93-
<span className="shrink-0 tabular-nums">{tokens(used)}</span>
107+
<span className="shrink-0 tabular-nums">
108+
{share === null ? tokens(used) : u.share(planPercent(share))}
109+
</span>
94110
</div>
95111
<div aria-hidden className="h-1 w-full bg-sunken">
96112
<div
97113
className="h-full origin-left animate-grow bg-work"
98-
style={{ width: `${(used / most) * 100}%` }}
114+
style={{ width: `${(weight(bot) / most) * 100}%` }}
99115
/>
100116
</div>
101-
<span className="text-muted text-xs">{u.detail(bot.crew, bot.turns)}</span>
117+
<span className="text-muted text-xs">
118+
{u.detail(bot.crew, bot.turns)}
119+
{share !== null && ` · ${u.tokensUsed(tokens(used))}`}
120+
</span>
102121
</div>
103122
</li>
104123
);
@@ -108,13 +127,18 @@ function Total({ bots }: { bots: BotTokens[] }) {
108127
const u = useT().account.usage.tokens;
109128
const used = bots.reduce((sum, bot) => sum + usedTokens(bot.tokens), 0);
110129
const turns = bots.reduce((sum, bot) => sum + bot.turns, 0);
130+
const learned = bots.every((bot) => bot.planShare !== null);
131+
const share = bots.reduce((sum, bot) => sum + (bot.planShare ?? 0), 0);
111132
return (
112133
<li className="flex flex-col gap-1 border-line border-t pt-3">
113134
<div className="flex items-baseline justify-between gap-3 font-semibold">
114135
<span>{u.total}</span>
115-
<span className="tabular-nums">{tokens(used)}</span>
136+
<span className="tabular-nums">{learned ? u.share(planPercent(share)) : tokens(used)}</span>
116137
</div>
117-
<span className="text-muted text-xs">{u.detail(null, turns)}</span>
138+
<span className="text-muted text-xs">
139+
{u.detail(null, turns)}
140+
{learned && ` · ${u.tokensUsed(tokens(used))}`}
141+
</span>
118142
</li>
119143
);
120144
}

‎app/src/features/account/tokens.test.tsx‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { App } from "../../App";
44
import type { Client } from "../../lib/client";
55
import { FakeBotloft } from "../../lib/fake";
66
import { FakeHost } from "../../lib/fakeHost";
7+
import { planPercent } from "../../lib/format";
78
import { periodStart } from "./TokensByBot";
89

910
afterEach(cleanup);
@@ -30,7 +31,7 @@ describe("tokens by bot", () => {
3031
fake.chat.turn(writer.id);
3132

3233
const dialog = await openUsage(fake);
33-
const list = await within(dialog).findByRole("list", { name: "Tokens by bot" });
34+
const list = await within(dialog).findByRole("list", { name: "Use by bot" });
3435
const rows = within(list).getAllByRole("listitem");
3536
// Each turn: 12 new, 1,800 into the cache and 640 written count; 24,000 reread do not.
3637
expect(rows.map((row) => row.textContent)).toEqual([
@@ -59,7 +60,7 @@ describe("tokens by bot", () => {
5960
fake.chat.turn(scout.id);
6061

6162
const dialog = await openUsage(fake);
62-
const list = await within(dialog).findByRole("list", { name: "Tokens by bot" });
63+
const list = await within(dialog).findByRole("list", { name: "Use by bot" });
6364
expect(
6465
within(list)
6566
.getAllByRole("listitem")
@@ -75,7 +76,7 @@ describe("tokens by bot", () => {
7576
fake.chat.turn(fake.addBot(news.id, "Scout", "Finds stories").id);
7677

7778
const dialog = await openUsage(fake);
78-
const list = await within(dialog).findByRole("list", { name: "Tokens by bot" });
79+
const list = await within(dialog).findByRole("list", { name: "Use by bot" });
7980
expect(
8081
within(list)
8182
.getAllByRole("listitem")
@@ -99,3 +100,38 @@ describe("tokens by bot", () => {
99100
expect(periodStart("all", now)).toBe(0);
100101
});
101102
});
103+
104+
describe("each bot's share of the weekly plan", () => {
105+
test("shows once learned, with the tokens beside it, and says it is an estimate", async () => {
106+
const fake = new FakeBotloft();
107+
const ops = fake.addCrew("Ops");
108+
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
109+
fake.chat.turn(scout.id);
110+
fake.chat.turn(scout.id);
111+
// 4,904 tokens at 0.000001 each: 0.49% of the week.
112+
fake.planSharePerToken = 0.000_001;
113+
114+
const dialog = await openUsage(fake);
115+
const list = await within(dialog).findByRole("list", { name: "Use by bot" });
116+
expect(within(list).getByRole("listitem").textContent).toBe(
117+
"Scout≈ 0.5% of the weekOps · Worked 2 times · 4.9k tokens",
118+
);
119+
expect(dialog.textContent).toContain("≈ Estimated:");
120+
fireEvent.click(within(dialog).getByRole("radio", { name: "Last hour" }));
121+
expect(await within(dialog).findByText("≈ 0.5% of the week")).toBeDefined();
122+
});
123+
124+
test("until learned, the tokens and a note that the share comes later", async () => {
125+
const fake = new FakeBotloft();
126+
fake.chat.turn(fake.addBot(fake.addCrew("Ops").id, "Scout", "Finds sources").id);
127+
const dialog = await openUsage(fake);
128+
expect(await within(dialog).findByText(/shows up once the plan has gone up/)).toBeDefined();
129+
});
130+
131+
test("a share reads as a short percentage", () => {
132+
expect(planPercent(0.045)).toBe("4.5%");
133+
expect(planPercent(0.123)).toBe("12%");
134+
expect(planPercent(0.0000001)).toBe("<0.1%");
135+
expect(planPercent(0)).toBe("0%");
136+
});
137+
});

‎app/src/i18n/en/account.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,30 @@ export const account = {
2828
limited: "Limit reached. Your bots wait until it resets.",
2929
updated: (relative: string) => `Updated ${relative}`,
3030
tokens: {
31-
title: "Tokens by bot",
31+
title: "Use by bot",
3232
intro:
33-
"Tokens are the pieces of text a bot reads and writes. The count adds the text read and the replies written.",
33+
"How much of your weekly plan each bot used, and the tokens behind it: the pieces of text a bot reads and writes.",
3434
period: "Period",
35-
periods: { today: "Today", week: "7 days", month: "30 days", all: "All time" },
35+
periods: {
36+
hour: "Last hour",
37+
today: "Today",
38+
week: "7 days",
39+
month: "30 days",
40+
all: "All time",
41+
},
3642
empty: "No bot worked in this period.",
3743
failed: "Could not load the tokens",
3844
archived: "archived",
3945
/** `crew` is null on the total of all bots. */
4046
detail: (crew: string | null, times: number) =>
4147
`${crew === null ? "" : `${crew} · `}Worked ${times === 1 ? "once" : `${times} times`}`,
4248
total: "All bots",
49+
share: (percent: string) => `≈ ${percent} of the week`,
50+
tokensUsed: (count: string) => `${count} tokens`,
51+
estimate:
52+
"≈ Estimated: Botloft learns how much of your plan the bots' work takes from how the weekly plan rises while they work. What you use outside Botloft also raises it, so the bots may show a little more than they used.",
53+
learning:
54+
"Each bot's share of your weekly plan shows up once the plan has gone up a few points while the bots work. Until then, the tokens.",
4355
},
4456
},
4557
settings: {

‎app/src/i18n/es/account.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,30 @@ export const account: Messages["account"] = {
2828
limited: "Límite alcanzado. Tus bots esperan hasta que se renueve.",
2929
updated: (relative: string) => `Actualizado ${relative}`,
3030
tokens: {
31-
title: "Tokens por bot",
31+
title: "Uso por bot",
3232
intro:
33-
"Los tokens son los trozos de texto que un bot lee y escribe. La cuenta suma el texto leído y las respuestas escritas.",
33+
"Cuánto de tu plan semanal usó cada bot, y los tokens detrás: los trozos de texto que un bot lee y escribe.",
3434
period: "Período",
35-
periods: { today: "Hoy", week: "7 días", month: "30 días", all: "Todo" },
35+
periods: {
36+
hour: "Última hora",
37+
today: "Hoy",
38+
week: "7 días",
39+
month: "30 días",
40+
all: "Todo",
41+
},
3642
empty: "Ningún bot trabajó en este período.",
3743
failed: "No se pudieron cargar los tokens",
3844
archived: "archivado",
3945
/** `crew` is null on the total of all bots. */
4046
detail: (crew: string | null, times: number) =>
4147
`${crew === null ? "" : `${crew} · `}Trabajó ${times === 1 ? "1 vez" : `${times} veces`}`,
4248
total: "Todos los bots",
49+
share: (percent) => `≈ ${percent} de la semana`,
50+
tokensUsed: (count) => `${count} tokens`,
51+
estimate:
52+
"≈ Estimado: Botloft aprende cuánto de tu plan consume el trabajo de los bots según cuánto sube el plan semanal mientras trabajan. Lo que usas fuera de Botloft también lo sube, así que los bots pueden aparecer con un poco más de lo que usaron.",
53+
learning:
54+
"La parte de cada bot en tu plan semanal aparece cuando el plan haya subido unos puntos con los bots trabajando. Hasta entonces, los tokens.",
4355
},
4456
},
4557
settings: {

‎app/src/i18n/pt-BR/account.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,30 @@ export const account: Messages["account"] = {
2828
limited: "Limite atingido. Seus bots esperam até ele renovar.",
2929
updated: (relative: string) => `Atualizado ${relative}`,
3030
tokens: {
31-
title: "Tokens por bot",
31+
title: "Uso por bot",
3232
intro:
33-
"Tokens são os pedaços de texto que um bot lê e escreve. A conta soma o texto lido e as respostas escritas.",
33+
"Quanto do seu plano semanal cada bot usou, e os tokens por trás disso: os pedaços de texto que um bot lê e escreve.",
3434
period: "Período",
35-
periods: { today: "Hoje", week: "7 dias", month: "30 dias", all: "Tudo" },
35+
periods: {
36+
hour: "Última hora",
37+
today: "Hoje",
38+
week: "7 dias",
39+
month: "30 dias",
40+
all: "Tudo",
41+
},
3642
empty: "Nenhum bot trabalhou nesse período.",
3743
failed: "Não deu para carregar os tokens",
3844
archived: "arquivado",
3945
/** `crew` is null on the total of all bots. */
4046
detail: (crew: string | null, times: number) =>
4147
`${crew === null ? "" : `${crew} · `}Trabalhou ${times === 1 ? "1 vez" : `${times} vezes`}`,
4248
total: "Todos os bots",
49+
share: (percent) => `≈ ${percent} da semana`,
50+
tokensUsed: (count) => `${count} tokens`,
51+
estimate:
52+
"≈ Estimativa: o Botloft aprende quanto do seu plano o trabalho dos bots consome pelo quanto o plano semanal sobe enquanto eles trabalham. O que você usa fora do Botloft também faz o plano subir, então os bots podem aparecer com um pouco mais do que usaram.",
53+
learning:
54+
"A parte de cada bot no seu plano semanal aparece depois que o plano subir alguns pontos com os bots trabalhando. Até lá, os tokens.",
4355
},
4456
},
4557
settings: {

‎app/src/lib/fake.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ export class FakeBotloft implements BotloftApi {
8989
readonly lesson = new FakeLesson(this);
9090
/** Clock for created and updated times. */
9191
now = Date.now();
92+
/** Plan share per token for `usage.tokens`; `null`, not learned yet. */
93+
planSharePerToken: number | null = null;
9294
closed = false;
9395
private state: ConnectionState = { kind: "open", daemonVersion: "0.1.0" };
9496
private readonly listeners = new Set<(event: ServerEvent) => void>();

‎app/src/lib/fakeUsage.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// The fake daemon's tokens per bot (spec 8.7), summed from the `turn` items
2-
// of the fake chats, as the daemon sums its stored ones.
2+
// of the fake chats, as the daemon sums its stored ones. The share of the
3+
// plan is the tokens times `planSharePerToken`, once a test sets it.
34

45
import type { FakeBotloft, Handlers } from "./fake";
56
import { usedTokens } from "./format";
@@ -24,6 +25,7 @@ export function usageHandlers(fake: FakeBotloft): Pick<Handlers, "usage.tokens">
2425
archived: bot.archivedAt !== null,
2526
turns: 0,
2627
tokens: { input: 0, cacheWrite: 0, reloaded: 0, cacheRead: 0, output: 0 },
28+
planShare: null,
2729
};
2830
byBot.set(bot.id, entry);
2931
}
@@ -37,6 +39,10 @@ export function usageHandlers(fake: FakeBotloft): Pick<Handlers, "usage.tokens">
3739
output: entry.tokens.output + output,
3840
};
3941
}
42+
const rate = fake.planSharePerToken;
43+
for (const entry of byBot.values()) {
44+
entry.planShare = rate === null ? null : usedTokens(entry.tokens) * rate;
45+
}
4046
return [...byBot.values()].sort(
4147
(a, b) =>
4248
usedTokens(b.tokens) - usedTokens(a.tokens) ||

‎app/src/lib/format.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ export function fileSize(bytes: number): string {
9393
}
9494

9595
/** Tokens as the owner reads them: "850", "24.3k", "556k", "1M", "1.2M". */
96+
/** A share of the plan, 0 to 1, as a percentage: "4.5%", "<0.1%". */
97+
export function planPercent(share: number): string {
98+
const percent = share * 100;
99+
if (percent <= 0) {
100+
return `${decimal(0, 0)}%`;
101+
}
102+
if (percent < 0.05) {
103+
return `<${decimal(0.1, 1)}%`;
104+
}
105+
return `${decimal(percent, percent < 10 ? 1 : 0)}%`;
106+
}
107+
96108
export function tokens(count: number): string {
97109
if (count < 1000) {
98110
return String(count);

‎app/src/lib/protocol.gen.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1107,7 +1107,12 @@ archived: boolean,
11071107
/**
11081108
* Turns that reported tokens.
11091109
*/
1110-
turns: number, tokens: TokenUsage, };
1110+
turns: number, tokens: TokenUsage,
1111+
/**
1112+
* About how much of the weekly plan the bot used in the period, 0 to
1113+
* 1; `null` until Botloft has learned it (spec 8.7).
1114+
*/
1115+
planShare: number | null, };
11111116

11121117
export type QuestionStatus = "open" | "answered" | "dismissed";
11131118

0 commit comments

Comments
 (0)