Add Month / Year / 5-Year granularity toggle to time-series views

- charts.js: monthlyBars + lineChart take an optional labelFn so axis and
  tooltip labels can render period (year / 5-year) keys instead of months.
- dashboard.js: period helpers (periodKey/periodKeys/sumByPeriod) bucket the
  Overview (income vs spending bars + cumulative net line), Spending, and
  Income time-series by the selected granularity. Calendar 5-year buckets
  (2015-2019, 2020-2024, 2025-2029). Choice persists in localStorage.
- dashboard.html: Month/Year/5-Year segmented control in the header.

With 7+ years of backfill, monthly bars hit 108 columns; Year collapses to
9 and 5-Year to 3 readable buckets.
This commit is contained in:
Kavi 2026-06-02 16:47:37 -04:00
parent e4e8f20555
commit 1c693581ae
3 changed files with 56 additions and 20 deletions

View File

@ -37,6 +37,8 @@
// months:[ym], series:[{key,color,values:{ym:amount}}]
function monthlyBars(container, months, series, opts) {
opts = opts || {};
const axisLab = opts.labelFn || (m => window.MT.MONTH_LABEL(m).split(" '")[0]);
const fullLab = opts.labelFn || window.MT.MONTH_LABEL;
clear(container);
const W = container.clientWidth || 800, H = opts.height || 240;
const padL = 52, padR = 12, padT = 14, padB = 26;
@ -61,11 +63,11 @@
const x = gx - totalW / 2 + si * (bw + gap);
const y = padT + innerH - h;
const r = el('rect', { x, y, width: bw, height: Math.max(0, h), rx: 2, fill: s.color, opacity: 0.92, class: 'mbar' });
r.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, s.label + ' · ' + window.MT.MONTH_LABEL(m), fmtFull(v), s.color));
r.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, s.label + ' · ' + fullLab(m), fmtFull(v), s.color));
r.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
svg.appendChild(r);
});
svg.appendChild(el('text', { x: gx, y: H - 8, 'text-anchor': 'middle', class: 'chart-axis' }, window.MT.MONTH_LABEL(m).split(" '")[0]));
svg.appendChild(el('text', { x: gx, y: H - 8, 'text-anchor': 'middle', class: 'chart-axis' }, axisLab(m)));
});
container.appendChild(svg);
}
@ -73,6 +75,8 @@
// ---------- line / area (cumulative net) ----------
function lineChart(container, months, valuesByMonth, opts) {
opts = opts || {};
const axisLab = opts.labelFn || (m => window.MT.MONTH_LABEL(m).split(" '")[0]);
const fullLab = opts.labelFn || window.MT.MONTH_LABEL;
clear(container);
const W = container.clientWidth || 800, H = opts.height || 200;
const padL = 56, padR = 14, padT = 14, padB = 26;
@ -105,10 +109,10 @@
svg.appendChild(el('path', { d: dPath, fill: 'none', stroke: col, 'stroke-width': 2.4, 'stroke-linejoin': 'round' }));
pts.forEach((v, i) => {
const dot = el('circle', { cx: x(i), cy: y(v), r: 3.4, fill: cssv('--bg'), stroke: col, 'stroke-width': 2 });
dot.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, window.MT.MONTH_LABEL(months[i]), 'running ' + fmtFull(v), col));
dot.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, fullLab(months[i]), 'running ' + fmtFull(v), col));
dot.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
svg.appendChild(dot);
svg.appendChild(el('text', { x: x(i), y: H - 8, 'text-anchor': 'middle', class: 'chart-axis' }, window.MT.MONTH_LABEL(months[i]).split(" '")[0]));
svg.appendChild(el('text', { x: x(i), y: H - 8, 'text-anchor': 'middle', class: 'chart-axis' }, axisLab(months[i])));
});
container.appendChild(svg);
}

View File

@ -20,6 +20,11 @@
<div class="sub">725 txns · 12 banks · 10 months</div>
</div>
<div class="stats">
<div class="seg" id="gran-seg" style="margin-right:10px;align-self:center;">
<button data-g="month" class="on">Month</button>
<button data-g="year">Year</button>
<button data-g="p5">5-Year</button>
</div>
<a href="money-trace.html" style="text-decoration:none; align-self:center; margin-right:6px;">
<div class="seg"><button style="color:var(--ink-mute)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M3 5h6c4 0 4 7 9 7h3M3 12h4c5 0 4 7 11 7h3M3 19h7"/></svg>

View File

@ -48,6 +48,24 @@
};
const sortEntries = m => Object.entries(m).sort((a, b) => b[1].value - a[1].value);
// ---------- time granularity (month / year / 5-year) ----------
let GRAN = localStorage.getItem('mt-gran') || 'month';
function periodKey(ym) {
const y = ym.slice(0, 4);
if (GRAN === 'year') return y;
if (GRAN === 'p5') { const b = Math.floor(+y / 5) * 5; return b + '' + (b + 4); }
return ym;
}
function periodLabel(k) { return GRAN === 'month' ? window.MT.MONTH_LABEL(k) : k; }
function periodKeys() { return [...new Set(window.MT.months.map(periodKey))].sort(); }
function sumByPeriod(txns, pred) {
const o = {}; periodKeys().forEach(k => o[k] = 0);
for (const t of txns) if (pred(t)) { const k = periodKey(t.ym); if (k in o) o[k] += t.amount; }
return o;
}
const granLabelFn = () => (GRAN === 'month' ? undefined : periodLabel);
const granPill = () => (GRAN === 'year' ? 'by year, CLP' : GRAN === 'p5' ? 'by 5-year period, CLP' : 'monthly, CLP');
const isExpense = t => t.flow_type === 'expense';
const isFee = t => t.flow_type === 'fee';
const isIncome = t => t.flow_type === 'income';
@ -80,12 +98,10 @@
const months = window.MT.months;
const introEl = document.getElementById('ov-intro');
if (introEl) introEl.innerHTML = `Across <b>${months.length} months</b>, <b>${window.MT.CLPk(TOT.realIn)}</b> came in and <b>${window.MT.CLPk(TOT.realOut)}</b> went out — but your statements logged <b>${window.MT.CLPk(TOT.gross)}</b> of total movement. Here's the real story versus the noise.`;
const inByM = {}, outByM = {}, netByM = {};
months.forEach(m => {
inByM[m] = sum(tx.filter(t => t.ym === m && (isIncome(t) || isInterIn(t))));
outByM[m] = sum(tx.filter(t => t.ym === m && (isExpense(t) || isFee(t) || isInterOut(t))));
netByM[m] = inByM[m] - outByM[m];
});
const keys = periodKeys();
const inByM = sumByPeriod(tx, t => isIncome(t) || isInterIn(t));
const outByM = sumByPeriod(tx, t => isExpense(t) || isFee(t) || isInterOut(t));
const netByM = {}; keys.forEach(k => netByM[k] = inByM[k] - outByM[k]);
const mult = TOT.gross / (TOT.realIn + TOT.realOut);
host.innerHTML = `
@ -95,7 +111,7 @@
${kpi('Net real', (TOT.net < 0 ? '' : '') + fmtBig(Math.abs(TOT.net)), 'income minus spending', col.ink())}
</div>
<div class="grid g-2-1">
<div class="card tall"><h3>Income vs spending by month<span class="pill">monthly, CLP</span></h3><div class="chart-host" id="ov-bars"></div>
<div class="card tall"><h3>Income vs spending<span class="pill">${granPill()}</span></h3><div class="chart-host" id="ov-bars"></div>
<div class="chips"><span class="chip"><i style="background:${col.income()}"></i>Money in</span><span class="chip"><i style="background:${col.spend()}"></i>Money out</span></div>
</div>
<div class="card tall"><h3>Real vs internal</h3>
@ -110,15 +126,15 @@
</div>
<div class="card" style="margin-top:16px;"><h3>Cumulative net position<span class="pill">running income spending</span></h3><div class="chart-host" id="ov-line"></div></div>
`;
C.monthlyBars($('#ov-bars'), months, [
C.monthlyBars($('#ov-bars'), keys, [
{ key: 'in', label: 'Money in', color: col.income(), values: inByM },
{ key: 'out', label: 'Money out', color: col.spend(), values: outByM }
], { height: 230 });
], { height: 230, labelFn: granLabelFn() });
C.donut($('#ov-donut'), [
{ label: 'Real money', value: TOT.realIn + TOT.realOut, color: col.income() },
{ label: 'Internal shuffle', value: TOT.internal, color: col.internal() }
], { size: 176, centerTop: 'x' + mult.toFixed(1), centerBot: 'multiplier' });
C.lineChart($('#ov-line'), months, netByM, { height: 200, color: col.income() });
C.lineChart($('#ov-line'), keys, netByM, { height: 200, color: col.income(), labelFn: granLabelFn() });
}
// ============================================================ TAB 2: SPENDING
@ -131,7 +147,7 @@
const palette = ['#ff5f73', '#ff7a6b', '#ff9460', '#ffae57', '#ffc24b', '#d98cff', '#8c9eff', '#6fd0ff', '#5fd0a0', '#7a8699'];
const catColor = i => i < palette.length ? palette[i] : col.mute();
const months = window.MT.months;
const spendByM = {}; months.forEach(m => spendByM[m] = sum(spend.filter(t => t.ym === m)));
const spKeys = periodKeys(); const spendByM = sumByPeriod(spend, () => true);
// top merchants
const byMerch = groupSum(tx.filter(isExpense), t => t.description || 'Unknown');
const merch = sortEntries(byMerch).slice(0, 10);
@ -147,7 +163,7 @@
<div class="card"><h3>Where it actually goes<span class="pill">by category</span></h3><div id="sp-cats"></div></div>
<div class="card"><h3>Top merchants<span class="pill">single payees</span></h3><div class="mlist" id="sp-merch"></div></div>
</div>
<div class="card" style="margin-top:16px;"><h3>Spending by month<span class="pill">CLP / month</span></h3><div class="chart-host" id="sp-month"></div></div>
<div class="card" style="margin-top:16px;"><h3>Spending<span class="pill">${granPill()}</span></h3><div class="chart-host" id="sp-month"></div></div>
<div class="card" style="margin-top:16px;"><h3>Categories &amp; merchants<span class="pill">click to expand</span></h3><div id="sp-breakdown"></div></div>
`;
C.hbars($('#sp-cats'), cats.map(([label, d], i) => ({
@ -164,7 +180,7 @@
row.addEventListener('mouseleave', () => window.MTtip.hide());
ml.appendChild(row);
});
C.monthlyBars($('#sp-month'), months, [{ key: 'sp', label: 'Spending', color: col.spend(), values: spendByM }], { height: 200 });
C.monthlyBars($('#sp-month'), spKeys, [{ key: 'sp', label: 'Spending', color: col.spend(), values: spendByM }], { height: 200, labelFn: granLabelFn() });
renderCategoryBreakdown($('#sp-breakdown'), cats, catColor, total);
}
@ -221,7 +237,7 @@
const srcs = sortEntries(bySrc);
const palette = ['#2ee6a6', '#3ad6b0', '#46c6ba', '#52b6c4', '#5ea6ce', '#b98cff', '#8c9eff', '#7a8699'];
const months = window.MT.months;
const incByM = {}; months.forEach(m => incByM[m] = sum(inc.filter(t => t.ym === m)));
const inKeys = periodKeys(); const incByM = sumByPeriod(inc, () => true);
const top3 = srcs.slice(0, 3).reduce((a, [, d]) => a + d.value, 0);
const conc = Math.round(top3 / total * 100);
@ -238,7 +254,7 @@
<div class="chips" id="in-chips" style="margin-top:18px;"></div>
</div>
</div>
<div class="card" style="margin-top:16px;"><h3>Income by month<span class="pill">CLP / month</span></h3><div class="chart-host" id="in-month"></div></div>
<div class="card" style="margin-top:16px;"><h3>Income<span class="pill">${granPill()}</span></h3><div class="chart-host" id="in-month"></div></div>
`;
C.hbars($('#in-src'), srcs.map(([label, d], i) => ({
label, value: d.value, color: i < palette.length ? palette[i] : col.mute(),
@ -252,7 +268,7 @@
if (otherV > 0) donutData.push({ label: 'Other sources', value: otherV, color: col.mute() });
C.donut($('#in-donut'), donutData, { size: 176, centerTop: window.MT.CLPk(total).replace('$', '$'), centerBot: 'total in' });
$('#in-chips').innerHTML = donutData.map(d => `<span class="chip"><i style="background:${d.color}"></i>${esc(d.label)} <b>${Math.round(d.value / total * 100)}%</b></span>`).join('');
C.monthlyBars($('#in-month'), months, [{ key: 'in', label: 'Income', color: col.income(), values: incByM }], { height: 200 });
C.monthlyBars($('#in-month'), inKeys, [{ key: 'in', label: 'Income', color: col.income(), values: incByM }], { height: 200, labelFn: granLabelFn() });
}
// ============================================================ TAB 4: CYCLES
@ -357,6 +373,17 @@
}
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => show(t.dataset.tab)));
// granularity toggle (Month / Year / 5-Year)
function rerenderActive() { const cur = document.querySelector('.tab.on'); if (cur && TOT) RENDERERS[cur.dataset.tab]($('#host-' + cur.dataset.tab)); }
document.querySelectorAll('#gran-seg button').forEach(b => {
b.classList.toggle('on', b.dataset.g === GRAN);
b.addEventListener('click', () => {
GRAN = b.dataset.g; localStorage.setItem('mt-gran', GRAN);
document.querySelectorAll('#gran-seg button').forEach(x => x.classList.toggle('on', x === b));
rerenderActive();
});
});
let rt; addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { const cur = document.querySelector('.tab.on'); if (cur && TOT) RENDERERS[cur.dataset.tab]($('#host-' + cur.dataset.tab)); }, 160); });
// expose for tweaks re-render (no-op until data is loaded)