diff --git a/docs/pr-media/2103/daily-tokens-365-desktop.png b/docs/pr-media/2103/daily-tokens-365-desktop.png new file mode 100644 index 00000000..ab1a4196 Binary files /dev/null and b/docs/pr-media/2103/daily-tokens-365-desktop.png differ diff --git a/static/panels.js b/static/panels.js index 18548394..08b4f0d3 100644 --- a/static/panels.js +++ b/static/panels.js @@ -2918,6 +2918,66 @@ function _renderLlmWikiStatus(d) { `; } +/** + * 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 = `
${esc(t('insights_daily_tokens'))}
` + - 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 `
${showLabel ? esc(String(r.date).slice(5)) : ''}
`; + 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 `
${showLabel ? esc(labelText) : ''}
`; }).join('') + `
${esc(t('insights_input_tokens'))}${esc(t('insights_output_tokens'))}
`; } else { diff --git a/static/style.css b/static/style.css index 2bc8ce52..3f66b4eb 100644 --- a/static/style.css +++ b/static/style.css @@ -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;} diff --git a/tests/test_insights.py b/tests/test_insights.py index 3bc48fc7..fe2501a2 100644 --- a/tests/test_insights.py +++ b/tests/test_insights.py @@ -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'