Skip to content

Commit 0328ebb

Browse files
committed
fix(web): label every bar in the Overview "spend by month" chart
The chart drew a value only on the tallest bar (plus the midline axis label), so at a glance just two dollar figures showed and every other month read as unlabelled. Label each bar with its own spend when the bars are wide enough to fit the text without colliding; when there are too many months to fit, fall back to labelling just the peak as before.
1 parent b8e737a commit 0328ebb

2 files changed

Lines changed: 32 additions & 6 deletions

File tree

src/opentab/webpage.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -592,15 +592,19 @@
592592
const gap = 2;
593593
const bw = Math.max(3, Math.min(46, (VW - 2 * padX) / n - gap));
594594
const step = bw + gap;
595+
// A value on top of every bar when they're wide enough to fit the label without
596+
// colliding; when too narrow (many months) fall back to labelling just the tallest.
597+
const valueEach = step >= 34;
595598
const x0 = (VW - (n * step - gap)) / 2;
596599
const plotH = VH - padT - padB;
597600
const svg = s('svg', { viewBox: '0 0 ' + VW + ' ' + VH, class: 'chart', role: 'img',
598601
'aria-label': 'spend by month' });
599602
for (const f of [0.5, 1]) {
600603
const y = padT + (1 - f) * plotH;
601604
svg.appendChild(s('line', { x1: x0, y1: y, x2: VW - x0, y2: y, stroke: thc('line'), 'stroke-width': 1 }));
602-
// the peak bar carries its own direct label, so only the midline gets an axis label
603-
if (f !== 1) svg.appendChild(s('text', { x: VW - x0, y: y - 4, 'text-anchor': 'end', 'font-size': 10, fill: thc('mut'), text: moneyLabel(peak * f) }));
605+
// The midline gets an axis label only when the bars aren't individually labelled;
606+
// with per-bar values it's redundant and collides with the rightmost bar's label.
607+
if (f !== 1 && !valueEach) svg.appendChild(s('text', { x: VW - x0, y: y - 4, 'text-anchor': 'end', 'font-size': 10, fill: thc('mut'), text: moneyLabel(peak * f) }));
604608
}
605609
svg.appendChild(s('line', { x1: x0, y1: VH - padB, x2: VW - x0, y2: VH - padB, stroke: thc('axis'), 'stroke-width': 1 }));
606610
const peakIdx = rows.findIndex(r => r.cost === Math.max(...rows.map(q => q.cost)));
@@ -613,7 +617,7 @@
613617
onclick: () => { go('m', r.month); } });
614618
g.appendChild(s('rect', { class: 'hit', x, y: padT, width: step, height: VH - padT - padB }));
615619
if (hgt > 0) g.appendChild(s('path', { d: roundTop(x, y, bw, hgt, 3), fill: thc('accent') }));
616-
if (i === peakIdx && r.cost > 0)
620+
if (r.cost > 0 && (valueEach || i === peakIdx))
617621
g.appendChild(s('text', { x: x + bw / 2, y: y - 5, 'text-anchor': 'middle', 'font-size': 10, fill: thc('ink2'), text: moneyLabel(r.cost) }));
618622
if (i % labelEvery === 0)
619623
g.appendChild(s('text', { x: x + bw / 2, y: VH - 7, 'text-anchor': 'middle', 'font-size': 9.5, fill: thc('mut'), text: monthLabel(r.month) }));
@@ -1136,11 +1140,18 @@
11361140
const gap = n > 40 ? 1.5 : 3;
11371141
const bw = Math.max(2, Math.min(52, (VW - 2 * padX) / n - gap));
11381142
const step = bw + gap, x0 = (VW - (n * step - gap)) / 2, plotH = VH - padT - padB;
1143+
// Label every bar with its own value when the bars are wide enough to fit the text
1144+
// without colliding; when too narrow (a fully packed month, like Weekly over many
1145+
// weeks) fall back to labelling just the tallest. Daily keeps bars wide by charting
1146+
// only its active days (trendDaily), so the common case labels every bar.
1147+
const valueEach = step >= 38;
11391148
const svg = s('svg', { viewBox: '0 0 ' + VW + ' ' + VH, class: 'tr-chart', role: 'img', 'aria-label': opts.aria || 'trend chart' });
11401149
for (const f of [0.5, 1]) {
11411150
const y = padT + (1 - f) * plotH;
11421151
svg.appendChild(s('line', { x1: x0, y1: y, x2: VW - x0, y2: y, stroke: thc('line'), 'stroke-width': 1 }));
1143-
if (f !== 1) svg.appendChild(s('text', { x: VW - x0, y: y - 4, 'text-anchor': 'end', 'font-size': 11, fill: thc('mut'), text: moneyLabel(peak * f) }));
1152+
// The midline gets an axis label only when the bars aren't individually labelled;
1153+
// with per-bar values it's redundant and collides with the rightmost bar's label.
1154+
if (f !== 1 && !valueEach) svg.appendChild(s('text', { x: VW - x0, y: y - 4, 'text-anchor': 'end', 'font-size': 11, fill: thc('mut'), text: moneyLabel(peak * f) }));
11441155
}
11451156
svg.appendChild(s('line', { x1: x0, y1: VH - padB, x2: VW - x0, y2: VH - padB, stroke: thc('axis'), 'stroke-width': 1 }));
11461157
const peakVal = Math.max(...vals), peakIdx = vals.indexOf(peakVal), tickEvery = Math.max(1, Math.ceil(n / 18));
@@ -1150,7 +1161,7 @@
11501161
const g = s('g', { class: 'bg', tip: () => (p.tip || (p.label + '\n' + money(p.value))), onclick: p.nav || null });
11511162
g.appendChild(s('rect', { class: 'hit', x, y: padT, width: step, height: VH - padT - padB }));
11521163
if (hgt > 0) g.appendChild(s('path', { d: roundTop(x, y, bw, hgt, Math.min(3, bw / 2)), fill: thc('accent') }));
1153-
if (i === peakIdx && p.value > 0)
1164+
if (p.value > 0 && (valueEach || i === peakIdx))
11541165
g.appendChild(s('text', { x: x + bw / 2, y: y - 5, 'text-anchor': 'middle', 'font-size': 11, fill: thc('ink2'), text: moneyLabel(p.value) }));
11551166
if (i % tickEvery === 0 || i === n - 1)
11561167
g.appendChild(s('text', { x: x + bw / 2, y: VH - 8, 'text-anchor': 'middle', 'font-size': 10, fill: thc('mut'), text: p.label }));
@@ -1195,8 +1206,14 @@
11951206
const idx = Math.max(0, Math.min(TRENDS.monthIdx, months.length - 1)), month = months[idx];
11961207
const byDay = new Map();
11971208
W.filter(w => w.date.startsWith(month)).forEach(w => { const d = +w.date.slice(8, 10); byDay.set(d, (byDay.get(d) || 0) + cost(w)); });
1209+
// Chart only up to the last day that has spend, not the whole calendar month: an
1210+
// in-progress month (e.g. the current one) shouldn't reserve its empty trailing days,
1211+
// which squeeze the bars narrow. Trimming keeps them as wide as Weekly/Monthly so each
1212+
// bar carries its own label instead of colliding.
1213+
let last = 0;
1214+
for (let d = 1; d <= daysInMonth(month); d++) if ((byDay.get(d) || 0) > 0) last = d;
11981215
const pairs = [];
1199-
for (let d = 1; d <= daysInMonth(month); d++) {
1216+
for (let d = 1; d <= last; d++) {
12001217
const date = month + '-' + String(d).padStart(2, '0'), v = byDay.get(d) || 0;
12011218
pairs.push({ label: String(d), value: v, tip: date + '\n' + money(v), nav: v > 0 ? (() => { closeTrends(); go('d', date); }) : null });
12021219
}

test_opentab.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8653,6 +8653,15 @@ def test_web_html_command_writes_the_report_file():
86538653
assert "keydown" in text # the j/k/Tab/h/l/Esc/$/T/P/R handler
86548654

86558655

8656+
def test_web_daily_trend_charts_only_active_days():
8657+
page = ot.render_html(ot.build_payload(app_with([workflow("w1", "2026-07-01 10:00:00")])))
8658+
# The Daily tab charts only up to the last day with spend, not the full calendar
8659+
# month, so an in-progress month keeps bars as wide as Weekly/Monthly (each with its
8660+
# own on-top label) instead of squeezing 31 slots and colliding the labels.
8661+
assert "> 0) last = d" in page
8662+
assert "for (let d = 1; d <= last; d++)" in page
8663+
8664+
86568665
def test_web_meta_carries_the_baked_theme():
86578666
app = app_with([workflow("w1", "2026-05-01 10:00:00")])
86588667
app.args.theme = "gruvbox" # --theme sets the browser's initial theme

0 commit comments

Comments
 (0)