@vimoxshah/tokenflow 1.1.2 → 1.2.0
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.
- package/CHANGELOG.md +180 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-menubar-app.sh +3 -1
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- package/src/ui/views/whatif.js +196 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Rhythm tab: how the work happens — focus, switching, and when a turn
|
|
3
|
+
* costs most.
|
|
4
|
+
*
|
|
5
|
+
* Derived only from `ctx.bundle.sessions` and the per-hour rollup behind
|
|
6
|
+
* `ctx.view` (`view.hourly.buckets`, the same rollup the Time patterns tab
|
|
7
|
+
* charts). All the arithmetic lives in `src/analytics/rhythm.js`, a pure
|
|
8
|
+
* module with its own tests; this file only turns that into markup.
|
|
9
|
+
*/
|
|
10
|
+
import { el, columns, table, SEQ } from '../charts.js';
|
|
11
|
+
import { filterSessions } from '../../analytics/aggregate.js';
|
|
12
|
+
import { deepWork, switching, costliestHour, focusDays, rhythmSummary } from '../../analytics/rhythm.js';
|
|
13
|
+
|
|
14
|
+
/** @typedef {import('./index.js').ViewContext} ViewContext */
|
|
15
|
+
|
|
16
|
+
export const id = 'rhythm';
|
|
17
|
+
export const label = 'Rhythm';
|
|
18
|
+
export const order = 115;
|
|
19
|
+
export const css = './styles/rhythm.css';
|
|
20
|
+
|
|
21
|
+
// The same number-bolding pass the Overview's story strip uses, so a figure
|
|
22
|
+
// (a number with its unit — "50.0%", "01:00", "$2.10") gets the figure face
|
|
23
|
+
// and the words around it stay words. Copied rather than imported: a view
|
|
24
|
+
// must not import app.js.
|
|
25
|
+
const FIGURE_RE = /((?:\$|[+\-−]|r = )?\d+(?:[.,]\d+)*(?:\s?(?:[KMB](?![a-z])|×|x(?![a-z])|%|days?|hours?|min(?![a-z])))?)/g;
|
|
26
|
+
const STORY_ICONS = ['🎯', '🔀', '💰'];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {ViewContext} ctx
|
|
30
|
+
* @returns {HTMLElement}
|
|
31
|
+
*/
|
|
32
|
+
export function view(ctx) {
|
|
33
|
+
const { fmt } = ctx;
|
|
34
|
+
const { compact, usd, pct, int, hourLabel, shortDate, longDate, humanDuration } = fmt;
|
|
35
|
+
const v = ctx.view;
|
|
36
|
+
|
|
37
|
+
// filterSessions applies the date, provider, model and other dimension
|
|
38
|
+
// filters — the same predicate set the cube uses — but not hourFrom/hourTo:
|
|
39
|
+
// that filter narrows which token EVENTS count, and a whole session cannot
|
|
40
|
+
// be half inside an hour window. The banner below says this plainly rather
|
|
41
|
+
// than letting the hour filter look like it silently does nothing.
|
|
42
|
+
const sessions = filterSessions(ctx.bundle.sessions || [], ctx.view.filters);
|
|
43
|
+
|
|
44
|
+
const deep = deepWork(sessions);
|
|
45
|
+
const sw = switching(sessions);
|
|
46
|
+
const costliest = costliestHour(v.hourly?.buckets || []);
|
|
47
|
+
const focus = focusDays(sessions);
|
|
48
|
+
const sentences = rhythmSummary({ deep, switching: sw, costliest });
|
|
49
|
+
|
|
50
|
+
const root = el('div', { class: 'grid' });
|
|
51
|
+
root.appendChild(storyStrip(sentences));
|
|
52
|
+
root.appendChild(el('div', { class: 'banner info' }, [el('span', {
|
|
53
|
+
text: 'Deep-work, switching and focus-day metrics use sessions filtered by date, provider, model and the other dimension filters. The hour-of-day filter narrows the charts on other tabs but does not apply to a whole session here, so it is left out of these three metrics; the cost-per-hour chart below still follows it.',
|
|
54
|
+
})]));
|
|
55
|
+
root.appendChild(kpiCard(ctx, { deep, sw, costliest }, { compact, usd, pct, int, hourLabel, shortDate }));
|
|
56
|
+
root.appendChild(costliestHourCard(ctx, costliest, { compact, usd, int, hourLabel }));
|
|
57
|
+
root.appendChild(switchingCard(ctx, sw, { int, shortDate, longDate }));
|
|
58
|
+
root.appendChild(focusDaysCard(ctx, focus, { compact, pct, int, shortDate, longDate }));
|
|
59
|
+
return root;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function storyStrip(sentences) {
|
|
63
|
+
const strip = el('div', { class: 'story' });
|
|
64
|
+
sentences.forEach((text, i) => {
|
|
65
|
+
const p = el('p', { class: 'story-text' });
|
|
66
|
+
let last = 0;
|
|
67
|
+
for (const m of text.matchAll(FIGURE_RE)) {
|
|
68
|
+
if (m.index > last) p.appendChild(document.createTextNode(text.slice(last, m.index)));
|
|
69
|
+
p.appendChild(el('strong', { text: m[0] }));
|
|
70
|
+
last = m.index + m[0].length;
|
|
71
|
+
}
|
|
72
|
+
if (last < text.length) p.appendChild(document.createTextNode(text.slice(last)));
|
|
73
|
+
strip.appendChild(el('div', { class: 'story-line' }, [
|
|
74
|
+
el('span', { class: 'story-ico', text: STORY_ICONS[i] || '•', 'aria-hidden': 'true' }),
|
|
75
|
+
p,
|
|
76
|
+
]));
|
|
77
|
+
});
|
|
78
|
+
return strip;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function kpiCard(ctx, { deep, sw, costliest }, { compact, usd, pct, int, hourLabel, shortDate }) {
|
|
82
|
+
const box = el('div', { class: 'cards' });
|
|
83
|
+
box.appendChild(ctx.kpi('Deep-work sessions', int(deep.count), deep.total ? `${pct(deep.shareOfSessions, 1)} of sessions` : '—'));
|
|
84
|
+
box.appendChild(ctx.kpi('Share of tokens', pct(deep.shareOfTokens, 1), deep.longest ? `longest ran ${ctx.fmt.humanDuration(deep.longest.durationMs)}` : '—'));
|
|
85
|
+
box.appendChild(ctx.kpi('Avg project switches / day', sw.average !== null ? sw.average.toFixed(1) : '—', sw.worst ? `worst ${int(sw.worst.switches)} on ${shortDate(sw.worst.date)}` : '—'));
|
|
86
|
+
const cHour = costliest?.costliest;
|
|
87
|
+
box.appendChild(ctx.kpi(
|
|
88
|
+
'Costliest hour',
|
|
89
|
+
cHour ? `${hourLabel(cHour.hour)}:00` : '—',
|
|
90
|
+
cHour
|
|
91
|
+
? (costliest.metric === 'cost' ? `${usd(cHour.value)} / priced request` : `${compact(cHour.value)} tokens / request`)
|
|
92
|
+
: (costliest ? 'no priced requests in this slice' : 'not derivable from the aggregate'),
|
|
93
|
+
));
|
|
94
|
+
return ctx.card(
|
|
95
|
+
'Rhythm at a glance',
|
|
96
|
+
'Deep work: 45 minutes or more of continuous activity, or 40 or more turns when duration is unknown. Switching: distinct projects touched in a day, minus one. Costliest hour: highest estimated cost per priced request (tokens per request when no price is configured), by hour of day.',
|
|
97
|
+
box,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function seqColor(value, max) {
|
|
102
|
+
if (value === null || value === undefined || !max) return 'var(--hairline)';
|
|
103
|
+
const t = Math.max(0, Math.min(1, value / max));
|
|
104
|
+
return SEQ[Math.round(t * (SEQ.length - 1))];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function costliestHourCard(ctx, costliest, { compact, usd, int, hourLabel }) {
|
|
108
|
+
if (!costliest) {
|
|
109
|
+
return ctx.card(
|
|
110
|
+
'Cost per request by hour',
|
|
111
|
+
'Estimated cost per priced request by hour of day, from the per-hour rollup behind this dashboard.',
|
|
112
|
+
ctx.emptyCard('Not derivable from the aggregate', 'This slice’s per-hour rollup carries neither a cost nor a token count to divide by requests.'),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const isCost = costliest.metric === 'cost';
|
|
116
|
+
const maxV = Math.max(0, ...costliest.hours.map((h) => h.value || 0));
|
|
117
|
+
const chartData = costliest.hours.map((h) => {
|
|
118
|
+
const marked = costliest.costliest && h.hour === costliest.costliest.hour;
|
|
119
|
+
return {
|
|
120
|
+
label: hourLabel(h.hour) + (marked ? '★' : ''),
|
|
121
|
+
value: h.value,
|
|
122
|
+
hour: h.hour,
|
|
123
|
+
color: seqColor(h.value, maxV),
|
|
124
|
+
fmtXLong: `${hourLabel(h.hour)}:00${marked ? ', the costliest hour' : ''}`,
|
|
125
|
+
extra: [{ color: null, name: isCost ? 'Priced requests' : 'Requests', value: int(h.requests) }],
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
const hint = !costliest.costliest
|
|
129
|
+
? (isCost
|
|
130
|
+
? 'ESTIMATE. No priced requests anywhere in this slice, so cost per request is not shown. Configure pricing to see it. Unpriced requests are excluded from the average rather than counted as free.'
|
|
131
|
+
: 'No requests in this slice.')
|
|
132
|
+
: (isCost
|
|
133
|
+
? `ESTIMATE. Divides estimated cost by PRICED requests only, per hour. An unpriced request is excluded, never counted as free. ★ marks the costliest hour, ${hourLabel(costliest.costliest.hour)}:00.`
|
|
134
|
+
: `No pricing configured, so this shows tokens per request instead. ★ marks the heaviest hour, ${hourLabel(costliest.costliest.hour)}:00.`);
|
|
135
|
+
return ctx.chartCard(
|
|
136
|
+
'rhythm-cost-hour',
|
|
137
|
+
isCost ? 'Cost per request by hour' : 'Tokens per request by hour',
|
|
138
|
+
hint,
|
|
139
|
+
(w) => columns({
|
|
140
|
+
data: chartData, width: w, height: 220,
|
|
141
|
+
fmtY: (x) => (isCost ? usd(x) : compact(x)),
|
|
142
|
+
valueLabel: isCost ? 'Cost / priced request' : 'Tokens / request',
|
|
143
|
+
ariaLabel: isCost ? 'Estimated cost per priced request by hour of day' : 'Tokens per request by hour of day',
|
|
144
|
+
}),
|
|
145
|
+
{
|
|
146
|
+
columns: [
|
|
147
|
+
{ key: 'hour', label: 'Hour', text: true, value: (r) => hourLabel(r.hour) + ':00' },
|
|
148
|
+
{ key: 'value', label: isCost ? 'Cost / priced request' : 'Tokens / request', value: (r) => (r.value === null ? null : (isCost ? usd(r.value) : compact(r.value))) },
|
|
149
|
+
{ key: 'requests', label: isCost ? 'Priced requests' : 'Requests', value: (r) => int(r.requests) },
|
|
150
|
+
],
|
|
151
|
+
rows: costliest.hours,
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function switchingCard(ctx, sw, { int, shortDate, longDate }) {
|
|
157
|
+
if (!sw.days.length) {
|
|
158
|
+
return ctx.card(
|
|
159
|
+
'Project switches per day',
|
|
160
|
+
'Switches = distinct projects touched in a day, minus one. Days with no sessions are not shown.',
|
|
161
|
+
ctx.emptyCard('No sessions with a known day', 'Project switching needs at least one session with a date in this slice.'),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const maxSwitch = Math.max(0, ...sw.days.map((d) => d.switches));
|
|
165
|
+
const data = sw.days.map((d) => ({
|
|
166
|
+
label: shortDate(d.date), value: d.switches, date: d.date,
|
|
167
|
+
color: seqColor(d.switches, maxSwitch),
|
|
168
|
+
fmtXLong: longDate(d.date),
|
|
169
|
+
extra: [{ color: null, name: 'Projects touched', value: int(d.projects) }],
|
|
170
|
+
}));
|
|
171
|
+
return ctx.chartCard(
|
|
172
|
+
'rhythm-switching',
|
|
173
|
+
'Project switches per day',
|
|
174
|
+
'Switches = distinct projects touched that day, minus one. Only days with at least one session are shown; a day with no sessions has no measurable switching, so it is left out rather than shown as zero.',
|
|
175
|
+
(w) => columns({
|
|
176
|
+
// Switches are always whole numbers, but the chart's own gridline
|
|
177
|
+
// picker can land on a half-step for a small max (e.g. 0, 0.5, 1, 1.5,
|
|
178
|
+
// 2) — rounding those through int() would print "1" twice. Showing the
|
|
179
|
+
// real half-step value keeps every gridline label distinct.
|
|
180
|
+
data, width: w, height: 200, fmtY: (x) => (Number.isInteger(x) ? int(x) : x.toFixed(1)), valueLabel: 'Switches',
|
|
181
|
+
ariaLabel: 'Project switches per day',
|
|
182
|
+
}),
|
|
183
|
+
{
|
|
184
|
+
columns: [
|
|
185
|
+
{ key: 'date', label: 'Date', text: true, value: (r) => longDate(r.date) },
|
|
186
|
+
{ key: 'projects', label: 'Projects touched', value: (r) => int(r.projects) },
|
|
187
|
+
{ key: 'switches', label: 'Switches', value: (r) => int(r.switches) },
|
|
188
|
+
],
|
|
189
|
+
rows: sw.days,
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function focusDaysCard(ctx, focus, { compact, pct, int, shortDate, longDate }) {
|
|
195
|
+
const hint = 'Days ranked by the share of that day’s tokens spent in deep-work sessions (45+ minutes continuous, or 40+ turns when duration is unknown). Days with zero tokens are excluded: a 0/0 share is not a real 0%.';
|
|
196
|
+
if (!focus.length) {
|
|
197
|
+
return ctx.card('Focus days', hint, ctx.emptyCard('No days to rank', 'Needs at least one session with tokens and a known day in this slice.'));
|
|
198
|
+
}
|
|
199
|
+
return ctx.card('Focus days', hint, table([
|
|
200
|
+
{ key: 'date', label: 'Date', text: true, value: (r) => longDate(r.date) },
|
|
201
|
+
{ key: 'share', label: 'Deep-work share of tokens', value: (r) => pct(r.share, 1) },
|
|
202
|
+
{ key: 'deepTokens', label: 'Deep-work tokens', value: (r) => compact(r.deepTokens) },
|
|
203
|
+
{ key: 'total', label: 'Total tokens', value: (r) => compact(r.total) },
|
|
204
|
+
{ key: 'sessions', label: 'Sessions', value: (r) => int(r.sessions) },
|
|
205
|
+
], focus, { emptyText: 'No focus days in this slice.' }));
|
|
206
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The What-if tab: reprice the same tokens at another model's rates.
|
|
3
|
+
*
|
|
4
|
+
* This is a what-if about PRICE ONLY. It says nothing about quality, latency
|
|
5
|
+
* or output length. Swapping a model changes all three, and this tab has no
|
|
6
|
+
* opinion on any of them.
|
|
7
|
+
*
|
|
8
|
+
* Everything here comes from the cube plus the shipped price book, so it
|
|
9
|
+
* works fully offline. The price book is built the exact same way the Cost
|
|
10
|
+
* tab builds it (`buildPriceBook(ctx.bundle.pricing || {})`, see
|
|
11
|
+
* src/analytics/index.js), so the two tabs never disagree about a rate.
|
|
12
|
+
*/
|
|
13
|
+
import { el } from '../charts.js';
|
|
14
|
+
import { buildPriceBook } from '../../core/pricing.js';
|
|
15
|
+
import { reprice, targetModelOptions } from '../../analytics/whatif.js';
|
|
16
|
+
|
|
17
|
+
/** @typedef {import('./index.js').ViewContext} ViewContext */
|
|
18
|
+
|
|
19
|
+
export const id = 'whatif';
|
|
20
|
+
export const label = 'What-if';
|
|
21
|
+
export const order = 105;
|
|
22
|
+
export const css = './styles/whatif.css';
|
|
23
|
+
|
|
24
|
+
/** How many of the current filters' top-cost models get their own row. Matches
|
|
25
|
+
* the eight-slot series/colour cap used elsewhere in the dashboard. */
|
|
26
|
+
const TOP_N = 8;
|
|
27
|
+
const FLAT_EPSILON = 0.005;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `ctx.S.whatif.mapping` is `{ fromModel: toModel }`, owned by this view, so a
|
|
31
|
+
* filter change or a re-render never loses the choices already made.
|
|
32
|
+
* @param {ViewContext} ctx
|
|
33
|
+
* @returns {{mapping: Record<string,string>}}
|
|
34
|
+
*/
|
|
35
|
+
function state(ctx) {
|
|
36
|
+
if (!ctx.S.whatif) ctx.S.whatif = { mapping: {} };
|
|
37
|
+
return ctx.S.whatif;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The top-cost models for the current filters, ranked by the same
|
|
42
|
+
* `.cost` field the Cost tab ranks and shows. Never re-derived, so the two
|
|
43
|
+
* tabs list the same models in the same order.
|
|
44
|
+
* @param {ViewContext} ctx
|
|
45
|
+
*/
|
|
46
|
+
function topModels(ctx) {
|
|
47
|
+
const models = ctx.view.dimensions.models || [];
|
|
48
|
+
return [...models]
|
|
49
|
+
.sort((a, b) => (b.cost ?? -Infinity) - (a.cost ?? -Infinity))
|
|
50
|
+
.slice(0, TOP_N);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function deltaClass(delta) {
|
|
54
|
+
if (delta === null || delta === undefined || !Number.isFinite(delta)) return 'whatif-flat';
|
|
55
|
+
if (Math.abs(delta) < FLAT_EPSILON) return 'whatif-flat';
|
|
56
|
+
return delta > 0 ? 'whatif-extra' : 'whatif-save';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `usd()` never signs a negative value on its own ("$-5.00"); the sign is
|
|
60
|
+
* what tells savings from extra cost here, so it is added explicitly. A
|
|
61
|
+
* missing delta delegates straight to `fmt.usd`, so it gets the same
|
|
62
|
+
* placeholder every other missing value on the dashboard gets. */
|
|
63
|
+
function signedUsd(v, fmt) {
|
|
64
|
+
if (v === null || v === undefined || !Number.isFinite(v)) return fmt.usd(v);
|
|
65
|
+
if (Math.abs(v) < FLAT_EPSILON) return fmt.usd(0);
|
|
66
|
+
const sign = v > 0 ? '+' : '-';
|
|
67
|
+
return sign + fmt.usd(Math.abs(v));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The target select for one model row: its own model is always an option
|
|
72
|
+
* (so the identity default always exists, even for an unpriced model), union
|
|
73
|
+
* the price table's own models and every priced model seen anywhere in the
|
|
74
|
+
* dataset, grouped into `<optgroup>`s by provider.
|
|
75
|
+
* @param {ViewContext} ctx
|
|
76
|
+
* @param {{name:string, provider:string}[]} targetOptions
|
|
77
|
+
* @param {object} row a `view.dimensions.models` row
|
|
78
|
+
* @param {number} idx position, for a stable DOM id
|
|
79
|
+
*/
|
|
80
|
+
function targetSelect(ctx, targetOptions, row, idx) {
|
|
81
|
+
const s = state(ctx);
|
|
82
|
+
const own = { name: row.key, provider: row.provider || 'Other' };
|
|
83
|
+
const all = [own, ...targetOptions.filter((o) => o.name !== row.key)];
|
|
84
|
+
const groups = new Map();
|
|
85
|
+
for (const o of all) {
|
|
86
|
+
if (!groups.has(o.provider)) groups.set(o.provider, new Set());
|
|
87
|
+
groups.get(o.provider).add(o.name);
|
|
88
|
+
}
|
|
89
|
+
const sel = el('select', { class: 'whatif-target', id: `whatif-target-${idx}`, 'aria-label': `Reprice ${row.key} at` });
|
|
90
|
+
for (const provider of [...groups.keys()].sort()) {
|
|
91
|
+
const grp = el('optgroup', { label: provider });
|
|
92
|
+
for (const name of [...groups.get(provider)].sort()) grp.appendChild(el('option', { value: name, text: name }));
|
|
93
|
+
sel.appendChild(grp);
|
|
94
|
+
}
|
|
95
|
+
const persisted = s.mapping[row.key];
|
|
96
|
+
// A persisted target that is no longer offered (e.g. a stale mapping from a
|
|
97
|
+
// very different dataset) falls back to identity rather than a select that
|
|
98
|
+
// shows nothing selected.
|
|
99
|
+
sel.value = persisted && all.some((o) => o.name === persisted) ? persisted : row.key;
|
|
100
|
+
sel.addEventListener('change', () => {
|
|
101
|
+
s.mapping[row.key] = sel.value;
|
|
102
|
+
ctx.rerender({ recompute: false });
|
|
103
|
+
});
|
|
104
|
+
return sel;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {ViewContext} ctx */
|
|
108
|
+
export function view(ctx) {
|
|
109
|
+
const root = el('div', { class: 'grid whatif' });
|
|
110
|
+
root.appendChild(ctx.sectionTitle('What-if pricing'));
|
|
111
|
+
root.appendChild(el('p', { class: 'hint', text: 'This is a what-if about price. It says nothing about quality, latency, or output length.' }));
|
|
112
|
+
|
|
113
|
+
const rows = topModels(ctx);
|
|
114
|
+
if (!rows.length) {
|
|
115
|
+
root.appendChild(ctx.emptyCard('No priced usage in the current filters.', 'Widen the date range or clear a filter to see models here.'));
|
|
116
|
+
return root;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const book = buildPriceBook(ctx.bundle.pricing || {});
|
|
120
|
+
const s = state(ctx);
|
|
121
|
+
const targetOptions = targetModelOptions({ book, seenModels: ctx.view.facets.model || [] });
|
|
122
|
+
const out = reprice({ rows, book, mapping: s.mapping });
|
|
123
|
+
|
|
124
|
+
root.appendChild(kpiRow(ctx, out));
|
|
125
|
+
root.appendChild(el('p', { class: 'hint whatif-footnote', text: `Estimated from the shipped price table ${book.version}. Same tokens, different rates. Not a claim about quality.` }));
|
|
126
|
+
if (out.overall.excluded > 0) {
|
|
127
|
+
root.appendChild(el('p', { class: 'hint whatif-footnote', text: `${out.overall.excluded} model(s) have no source price at all. They show no value and are excluded from the totals above.` }));
|
|
128
|
+
}
|
|
129
|
+
root.appendChild(pickerCard(ctx, rows, targetOptions));
|
|
130
|
+
root.appendChild(deltaChartCard(ctx, out));
|
|
131
|
+
return root;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function kpiRow(ctx, out) {
|
|
135
|
+
const { usd } = ctx.fmt;
|
|
136
|
+
const box = el('div', { class: 'cards' });
|
|
137
|
+
box.appendChild(ctx.kpi('Current (top models)', usd(out.overall.current), 'at each model\'s own rate'));
|
|
138
|
+
box.appendChild(ctx.kpi('What-if', usd(out.overall.whatif), 'at the selected targets'));
|
|
139
|
+
const delta = ctx.kpi('Delta', signedUsd(out.overall.delta, ctx.fmt), out.overall.partial ? 'partial: some rates are missing' : null);
|
|
140
|
+
delta.classList.add('whatif-delta-kpi', deltaClass(out.overall.delta));
|
|
141
|
+
box.appendChild(delta);
|
|
142
|
+
return box;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function pickerCard(ctx, rows, targetOptions) {
|
|
146
|
+
const body = el('div', { class: 'whatif-pickers' });
|
|
147
|
+
rows.forEach((row, idx) => {
|
|
148
|
+
const line = el('div', { class: 'whatif-picker-row' });
|
|
149
|
+
line.appendChild(el('span', { class: 'whatif-picker-label', text: row.key }));
|
|
150
|
+
line.appendChild(el('span', { class: 'whatif-picker-arrow', text: '→' }));
|
|
151
|
+
line.appendChild(targetSelect(ctx, targetOptions, row, idx));
|
|
152
|
+
body.appendChild(line);
|
|
153
|
+
});
|
|
154
|
+
return ctx.card('Reprice at', 'Pick a target model for each of the top models by estimated cost in the current filters. Defaults to the same model, which is a zero delta.', body);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function deltaChartCard(ctx, out) {
|
|
158
|
+
const { usd, compact } = ctx.fmt;
|
|
159
|
+
const models = out.models;
|
|
160
|
+
|
|
161
|
+
const tableSpec = {
|
|
162
|
+
columns: [
|
|
163
|
+
{ key: 'model', label: 'Model', text: true },
|
|
164
|
+
{ key: 'target', label: 'Target', text: true },
|
|
165
|
+
{ key: 'tokens', label: 'Tokens', value: (r) => compact(r.tokens) },
|
|
166
|
+
{ key: 'current', label: 'Current', value: (r) => (r.current === null ? null : usd(r.current)) },
|
|
167
|
+
{ key: 'whatif', label: 'What-if', value: (r) => (r.whatif === null ? null : usd(r.whatif)) },
|
|
168
|
+
{ key: 'delta', label: 'Delta', value: (r) => (r.delta === null ? null : signedUsd(r.delta, ctx.fmt)) },
|
|
169
|
+
{ key: 'partial', label: 'Partial', value: (r) => (r.partial ? 'partial' : null) },
|
|
170
|
+
],
|
|
171
|
+
rows: models,
|
|
172
|
+
};
|
|
173
|
+
return ctx.chartCard(
|
|
174
|
+
'whatif-delta',
|
|
175
|
+
'Delta per model',
|
|
176
|
+
'What-if minus current, per model. Orange is extra cost, blue is savings.',
|
|
177
|
+
// hbars' label formatter only ever sees one bar's numeric value, with no
|
|
178
|
+
// way back to the row, so a null delta is passed through as NaN (falsy,
|
|
179
|
+
// so it still draws a zero-width bar). `usd()` already renders NaN with
|
|
180
|
+
// the same placeholder it renders null with, so the label needs no
|
|
181
|
+
// special-casing here.
|
|
182
|
+
() => ctx.charts.hbars(models.map((m) => ({
|
|
183
|
+
label: m.model === m.target ? m.model : `${m.model} → ${m.target}`,
|
|
184
|
+
value: m.delta === null ? NaN : Math.abs(m.delta),
|
|
185
|
+
color: deltaClass(m.delta) === 'whatif-extra' ? 'var(--div-1)' : deltaClass(m.delta) === 'whatif-save' ? 'var(--div-5)' : 'var(--div-3)',
|
|
186
|
+
title: m.partial ? `${m.model}: partial, at least one rate is missing` : m.model,
|
|
187
|
+
rows: [
|
|
188
|
+
{ color: null, name: 'Tokens', value: compact(m.tokens) },
|
|
189
|
+
{ color: null, name: 'Current', value: usd(m.current) },
|
|
190
|
+
{ color: null, name: 'What-if', value: usd(m.whatif) },
|
|
191
|
+
{ color: null, name: 'Delta', value: signedUsd(m.delta, ctx.fmt) },
|
|
192
|
+
],
|
|
193
|
+
})), { fmt: (v) => usd(v), valueLabel: 'Delta (magnitude)' }),
|
|
194
|
+
tableSpec,
|
|
195
|
+
);
|
|
196
|
+
}
|