Merge pull request #2120 into stage-344
fix: bucket long-range daily token charts (closes #2103)
This commit is contained in:
commit
2cccb8abcc
4 changed files with 202 additions and 9 deletions
BIN
docs/pr-media/2103/daily-tokens-365-desktop.png
Normal file
BIN
docs/pr-media/2103/daily-tokens-365-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
|
|
@ -2918,6 +2918,66 @@ function _renderLlmWikiStatus(d) {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket daily token rows for chart display.
|
||||
* Returns rows unchanged when length <= 30 (per-day resolution).
|
||||
* For longer ranges, groups consecutive days into buckets:
|
||||
* 31–90 days → 2-day buckets
|
||||
* 91–180 days → 3-day buckets
|
||||
* 181–365 days → 8-day buckets
|
||||
* Result is always <= ~52 bars.
|
||||
* Each bucket row has:
|
||||
* - label: short label for axis (e.g. MM-DD or MM-DD–MM-DD)
|
||||
* - title: full tooltip title (e.g. 2026-01-01 – 2026-01-05)
|
||||
* - date: first date in bucket (used for date label slicing)
|
||||
* - input_tokens, output_tokens, sessions, cost: summed across bucket
|
||||
*/
|
||||
function _bucketDailyTokensForChart(rows) {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return [];
|
||||
const len = rows.length;
|
||||
if (len <= 30) return rows; // per-day resolution for 7/30-day ranges
|
||||
|
||||
// Target <= 75 bars; derive bucket size
|
||||
let bucketSize;
|
||||
if (len <= 90) {
|
||||
bucketSize = 2;
|
||||
} else if (len <= 180) {
|
||||
bucketSize = 3;
|
||||
} else if (len <= 365) {
|
||||
bucketSize = 8; // <=52 bars for 365 days (ceil(365/8)=46)
|
||||
} else {
|
||||
bucketSize = 8; // fallback for >365 (shouldn't occur in practice)
|
||||
}
|
||||
|
||||
const result = [];
|
||||
for (let i = 0; i < len; i += bucketSize) {
|
||||
const slice = rows.slice(i, i + bucketSize);
|
||||
const input_tokens = slice.reduce((s, r) => s + Number(r.input_tokens || 0), 0);
|
||||
const output_tokens = slice.reduce((s, r) => s + Number(r.output_tokens || 0), 0);
|
||||
const sessions = slice.reduce((s, r) => s + Number(r.sessions || 0), 0);
|
||||
const cost = slice.reduce((s, r) => s + Number(r.cost || 0), 0);
|
||||
|
||||
const firstDate = slice[0].date;
|
||||
const lastDate = slice[slice.length - 1].date;
|
||||
|
||||
// Label: short form for axis
|
||||
const firstLabel = String(firstDate).slice(5); // MM-DD
|
||||
const lastLabel = String(lastDate).slice(5);
|
||||
const label = (firstDate === lastDate) ? firstLabel : (firstLabel + '–' + lastLabel);
|
||||
|
||||
result.push({
|
||||
label,
|
||||
title: firstDate + (firstDate !== lastDate ? ' – ' + lastDate : ''),
|
||||
date: firstDate,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
sessions,
|
||||
cost,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function _renderInsights(d, box, wikiStatus) {
|
||||
const fmtNum = n => Number(n || 0).toLocaleString();
|
||||
const fmtCost = c => {
|
||||
|
|
@ -2937,21 +2997,24 @@ function _renderInsights(d, box, wikiStatus) {
|
|||
{ label: t('insights_cost'), value: fmtCost(d.total_cost), icon: li('dollar-sign', 18) },
|
||||
];
|
||||
|
||||
// Daily token trend
|
||||
// Daily token trend — bucket long ranges to avoid horizontal overflow
|
||||
const dailyTokens = Array.isArray(d.daily_tokens) ? d.daily_tokens : [];
|
||||
const chartRows = _bucketDailyTokensForChart(dailyTokens);
|
||||
let dailyHtml = '';
|
||||
if (dailyTokens.length) {
|
||||
const maxDailyTokens = Math.max(...dailyTokens.map(r => Number(r.input_tokens || 0) + Number(r.output_tokens || 0)), 1);
|
||||
const labelEvery = Math.max(Math.ceil(dailyTokens.length / 7), 1);
|
||||
if (chartRows.length) {
|
||||
const maxDailyTokens = Math.max(...chartRows.map(r => Number(r.input_tokens || 0) + Number(r.output_tokens || 0)), 1);
|
||||
const labelEvery = Math.max(Math.ceil(chartRows.length / 7), 1);
|
||||
dailyHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_daily_tokens'))}</div><div class="insights-daily-token-chart">` +
|
||||
dailyTokens.map((r, idx) => {
|
||||
chartRows.map((r, idx) => {
|
||||
const input = Number(r.input_tokens || 0);
|
||||
const output = Number(r.output_tokens || 0);
|
||||
const inputPct = Math.max((input / maxDailyTokens) * 100, input ? 2 : 0).toFixed(1);
|
||||
const outputPct = Math.max((output / maxDailyTokens) * 100, output ? 2 : 0).toFixed(1);
|
||||
const showLabel = idx === 0 || idx === dailyTokens.length - 1 || idx % labelEvery === 0;
|
||||
const title = `${r.date} · ${fmtTokens(input)} ${t('insights_input_tokens')} · ${fmtTokens(output)} ${t('insights_output_tokens')} · ${fmtCost(r.cost)} · ${fmtNum(r.sessions)} ${t('insights_sessions')}`;
|
||||
return `<div class="insights-daily-bar" title="${esc(title)}"><div class="insights-daily-stack" aria-label="${esc(title)}"><div class="insights-daily-bar-output" style="height:${outputPct}%"></div><div class="insights-daily-bar-input" style="height:${inputPct}%"></div></div><span>${showLabel ? esc(String(r.date).slice(5)) : ''}</span></div>`;
|
||||
const showLabel = idx === 0 || idx === chartRows.length - 1 || idx % labelEvery === 0;
|
||||
const titleDate = r.title || r.date;
|
||||
const title = `${titleDate} · ${fmtTokens(input)} ${t('insights_input_tokens')} · ${fmtTokens(output)} ${t('insights_output_tokens')} · ${fmtCost(r.cost)} · ${fmtNum(r.sessions)} ${t('insights_sessions')}`;
|
||||
const labelText = r.label !== undefined ? r.label : String(r.date).slice(5);
|
||||
return `<div class="insights-daily-bar" title="${esc(title)}"><div class="insights-daily-stack" aria-label="${esc(title)}"><div class="insights-daily-bar-output" style="height:${outputPct}%"></div><div class="insights-daily-bar-input" style="height:${inputPct}%"></div></div><span>${showLabel ? esc(labelText) : ''}</span></div>`;
|
||||
}).join('') +
|
||||
`</div><div class="insights-daily-legend"><span><i class="insights-daily-legend-input"></i>${esc(t('insights_input_tokens'))}</span><span><i class="insights-daily-legend-output"></i>${esc(t('insights_output_tokens'))}</span></div></div>`;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -3374,7 +3374,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
|||
.insights-model-cost,.insights-model-tokens{font-variant-numeric:tabular-nums;}
|
||||
.insights-model-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.insights-empty{font-size:12px;color:var(--muted);padding:12px 0;}
|
||||
.insights-daily-token-chart{height:180px;display:grid;grid-auto-flow:column;grid-auto-columns:minmax(10px,1fr);gap:4px;align-items:end;padding:6px 0 2px;border-bottom:1px solid var(--border);}
|
||||
.insights-daily-token-chart{height:180px;display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:4px;align-items:end;padding:6px 0 2px;border-bottom:1px solid var(--border);overflow:hidden;max-width:100%;}
|
||||
.insights-daily-bar{min-width:0;height:100%;display:flex;flex-direction:column;justify-content:flex-end;gap:4px;}
|
||||
.insights-daily-stack{height:150px;display:flex;flex-direction:column;justify-content:flex-end;background:var(--border,.15);border-radius:4px;overflow:hidden;}
|
||||
.insights-daily-bar-input{background:var(--accent);min-height:0;}
|
||||
|
|
|
|||
|
|
@ -162,3 +162,133 @@ def test_insights_frontend_has_daily_chart_styles_and_range_switching_hooks():
|
|||
assert ".insights-daily-token-chart" in STYLE_CSS
|
||||
assert ".insights-daily-bar-output" in STYLE_CSS
|
||||
assert ".insights-model-cost" in STYLE_CSS
|
||||
|
||||
|
||||
def _make_daily_rows(n):
|
||||
rows = []
|
||||
for i in range(n):
|
||||
rows.append({
|
||||
'date': f'2026-01-{i+1:02d}',
|
||||
'input_tokens': (i + 1) * 100,
|
||||
'output_tokens': (i + 1) * 50,
|
||||
'sessions': 1,
|
||||
'cost': (i + 1) * 0.01,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
# Python reference implementation of the JS bucketing logic, so we can
|
||||
# verify the JS implementation produces the same behavior without needing
|
||||
# a JS runtime.
|
||||
def _py_bucket(rows):
|
||||
if not isinstance(rows, list) or len(rows) == 0:
|
||||
return []
|
||||
n = len(rows)
|
||||
if n <= 30:
|
||||
return list(rows) # unchanged
|
||||
|
||||
if n <= 90:
|
||||
bucket_size = 2
|
||||
elif n <= 180:
|
||||
bucket_size = 3
|
||||
elif n <= 365:
|
||||
bucket_size = 8 # ≤52 bars for 365 days; shrink-safe with minmax(0,1fr)
|
||||
else:
|
||||
bucket_size = 8 # fallback for >365 (shouldn't occur in practice)
|
||||
|
||||
result = []
|
||||
for i in range(0, n, bucket_size):
|
||||
sl = rows[i:i + bucket_size]
|
||||
inp = sum(r['input_tokens'] for r in sl)
|
||||
out = sum(r['output_tokens'] for r in sl)
|
||||
sess = sum(r['sessions'] for r in sl)
|
||||
cost = sum(r['cost'] for r in sl)
|
||||
first = sl[0]['date']
|
||||
last = sl[-1]['date']
|
||||
first_lbl = first[5:] # MM-DD
|
||||
last_lbl = last[5:]
|
||||
result.append({
|
||||
'label': (first_lbl if first == last else first_lbl + '--' + last_lbl),
|
||||
'title': first + (' -- ' + last if first != last else ''),
|
||||
'date': first,
|
||||
'input_tokens': inp,
|
||||
'output_tokens': out,
|
||||
'sessions': sess,
|
||||
'cost': cost,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def test_insights_bucketing_helper_preserves_short_ranges():
|
||||
# _bucketDailyTokensForChart must exist in panels.js
|
||||
assert '_bucketDailyTokensForChart' in PANELS_JS
|
||||
|
||||
# 7-day: unchanged (≤ 30 threshold)
|
||||
rows7 = _make_daily_rows(7)
|
||||
bucketed7 = _py_bucket(rows7)
|
||||
assert len(bucketed7) == 7, f'7-day should stay 7 bars, got {len(bucketed7)}'
|
||||
assert bucketed7[0]['input_tokens'] == 100
|
||||
|
||||
# 30-day: exactly 30 → unchanged
|
||||
rows30 = _make_daily_rows(30)
|
||||
bucketed30 = _py_bucket(rows30)
|
||||
assert len(bucketed30) == 30, f'30-day should stay 30 bars, got {len(bucketed30)}'
|
||||
|
||||
# 31-day: bucketed
|
||||
rows31 = _make_daily_rows(31)
|
||||
bucketed31 = _py_bucket(rows31)
|
||||
assert len(bucketed31) < 31, f'31-day should be bucketed, got {len(bucketed31)}'
|
||||
assert len(bucketed31) <= 16 # ceil(31/2)
|
||||
|
||||
|
||||
def test_insights_bucketing_helper_bounds_long_ranges():
|
||||
# 90-day → 2-day buckets → 45 bars
|
||||
rows90 = _make_daily_rows(90)
|
||||
bucketed90 = _py_bucket(rows90)
|
||||
assert len(bucketed90) <= 45, f'90-day should be <=45 bars, got {len(bucketed90)}'
|
||||
assert len(bucketed90) > 0
|
||||
|
||||
# 365-day → 8-day buckets → 46 bars (≤52 threshold)
|
||||
rows365 = _make_daily_rows(365)
|
||||
bucketed365 = _py_bucket(rows365)
|
||||
assert len(bucketed365) <= 52, f'365-day should be <=52 bars, got {len(bucketed365)}'
|
||||
assert len(bucketed365) > 0
|
||||
# First bucket has 8 days: 100+200+300+400+500+600+700+800 = 3600
|
||||
assert bucketed365[0]['input_tokens'] == 3600
|
||||
assert bucketed365[0]['sessions'] == 8
|
||||
|
||||
|
||||
def test_insights_bucketing_helper_preserves_label_and_title_fields():
|
||||
# Short range → rows unchanged; no .label/.title keys
|
||||
rows10 = _make_daily_rows(10)
|
||||
bucketed10 = _py_bucket(rows10)
|
||||
assert bucketed10[0]['date'] == '2026-01-01'
|
||||
assert 'label' not in bucketed10[0]
|
||||
assert 'title' not in bucketed10[0]
|
||||
|
||||
# 90-day → bucket rows have .label and .title
|
||||
rows90 = _make_daily_rows(90)
|
||||
bucketed90 = _py_bucket(rows90)
|
||||
assert 'label' in bucketed90[0], 'bucket row must have .label'
|
||||
assert 'title' in bucketed90[0], 'bucket row must have .title'
|
||||
assert '2026-01-01' in bucketed90[0]['title'], f'title should include start date, got {bucketed90[0]["title"]}'
|
||||
assert len(bucketed90[0]['label']) <= 12, f'label should be short, got {bucketed90[0]["label"]}'
|
||||
|
||||
|
||||
def test_insights_render_loop_uses_bucket_helper():
|
||||
src = PANELS_JS
|
||||
daily_section_start = src.find('// Daily token trend')
|
||||
daily_section_end = src.find('// Models table', daily_section_start)
|
||||
daily_section = src[daily_section_start:daily_section_end]
|
||||
|
||||
assert '_bucketDailyTokensForChart' in daily_section, '_bucketDailyTokensForChart must be called in the render loop'
|
||||
assert 'const chartRows' in daily_section, 'chartRows variable must be used instead of dailyTokens.map directly'
|
||||
|
||||
|
||||
def test_insights_css_chart_shrink_safe():
|
||||
assert '.insights-daily-token-chart' in STYLE_CSS
|
||||
chart_line = [line for line in STYLE_CSS.splitlines() if '.insights-daily-token-chart' in line][0]
|
||||
# minmax(0,1fr) instead of minmax(12px,1fr) lets long-range bars shrink to fit the card
|
||||
assert 'minmax(0,1fr)' in chart_line, f'chart must use minmax(0,1fr) for shrink-safe columns, got: {chart_line}'
|
||||
assert 'overflow:hidden' in chart_line, 'chart must have overflow:hidden to prevent horizontal scroll'
|
||||
assert 'max-width:100%' in chart_line or 'max-width' in chart_line, 'chart should constrain max-width'
|
||||
|
|
|
|||
Loading…
Reference in a new issue