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:
parent
e4e8f20555
commit
1c693581ae
|
|
@ -37,6 +37,8 @@
|
||||||
// months:[ym], series:[{key,color,values:{ym:amount}}]
|
// months:[ym], series:[{key,color,values:{ym:amount}}]
|
||||||
function monthlyBars(container, months, series, opts) {
|
function monthlyBars(container, months, series, opts) {
|
||||||
opts = 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);
|
clear(container);
|
||||||
const W = container.clientWidth || 800, H = opts.height || 240;
|
const W = container.clientWidth || 800, H = opts.height || 240;
|
||||||
const padL = 52, padR = 12, padT = 14, padB = 26;
|
const padL = 52, padR = 12, padT = 14, padB = 26;
|
||||||
|
|
@ -61,11 +63,11 @@
|
||||||
const x = gx - totalW / 2 + si * (bw + gap);
|
const x = gx - totalW / 2 + si * (bw + gap);
|
||||||
const y = padT + innerH - h;
|
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' });
|
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());
|
r.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
|
||||||
svg.appendChild(r);
|
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);
|
container.appendChild(svg);
|
||||||
}
|
}
|
||||||
|
|
@ -73,6 +75,8 @@
|
||||||
// ---------- line / area (cumulative net) ----------
|
// ---------- line / area (cumulative net) ----------
|
||||||
function lineChart(container, months, valuesByMonth, opts) {
|
function lineChart(container, months, valuesByMonth, opts) {
|
||||||
opts = 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);
|
clear(container);
|
||||||
const W = container.clientWidth || 800, H = opts.height || 200;
|
const W = container.clientWidth || 800, H = opts.height || 200;
|
||||||
const padL = 56, padR = 14, padT = 14, padB = 26;
|
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' }));
|
svg.appendChild(el('path', { d: dPath, fill: 'none', stroke: col, 'stroke-width': 2.4, 'stroke-linejoin': 'round' }));
|
||||||
pts.forEach((v, i) => {
|
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 });
|
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());
|
dot.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
|
||||||
svg.appendChild(dot);
|
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);
|
container.appendChild(svg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@
|
||||||
<div class="sub">725 txns · 12 banks · 10 months</div>
|
<div class="sub">725 txns · 12 banks · 10 months</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats">
|
<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;">
|
<a href="money-trace.html" style="text-decoration:none; align-self:center; margin-right:6px;">
|
||||||
<div class="seg"><button style="color:var(--ink-mute)">
|
<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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,24 @@
|
||||||
};
|
};
|
||||||
const sortEntries = m => Object.entries(m).sort((a, b) => b[1].value - a[1].value);
|
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 isExpense = t => t.flow_type === 'expense';
|
||||||
const isFee = t => t.flow_type === 'fee';
|
const isFee = t => t.flow_type === 'fee';
|
||||||
const isIncome = t => t.flow_type === 'income';
|
const isIncome = t => t.flow_type === 'income';
|
||||||
|
|
@ -80,12 +98,10 @@
|
||||||
const months = window.MT.months;
|
const months = window.MT.months;
|
||||||
const introEl = document.getElementById('ov-intro');
|
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.`;
|
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 = {};
|
const keys = periodKeys();
|
||||||
months.forEach(m => {
|
const inByM = sumByPeriod(tx, t => isIncome(t) || isInterIn(t));
|
||||||
inByM[m] = sum(tx.filter(t => t.ym === m && (isIncome(t) || isInterIn(t))));
|
const outByM = sumByPeriod(tx, t => isExpense(t) || isFee(t) || isInterOut(t));
|
||||||
outByM[m] = sum(tx.filter(t => t.ym === m && (isExpense(t) || isFee(t) || isInterOut(t))));
|
const netByM = {}; keys.forEach(k => netByM[k] = inByM[k] - outByM[k]);
|
||||||
netByM[m] = inByM[m] - outByM[m];
|
|
||||||
});
|
|
||||||
const mult = TOT.gross / (TOT.realIn + TOT.realOut);
|
const mult = TOT.gross / (TOT.realIn + TOT.realOut);
|
||||||
|
|
||||||
host.innerHTML = `
|
host.innerHTML = `
|
||||||
|
|
@ -95,7 +111,7 @@
|
||||||
${kpi('Net real', (TOT.net < 0 ? '−' : '') + fmtBig(Math.abs(TOT.net)), 'income minus spending', col.ink())}
|
${kpi('Net real', (TOT.net < 0 ? '−' : '') + fmtBig(Math.abs(TOT.net)), 'income minus spending', col.ink())}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid g-2-1">
|
<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 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>
|
||||||
<div class="card tall"><h3>Real vs internal</h3>
|
<div class="card tall"><h3>Real vs internal</h3>
|
||||||
|
|
@ -110,15 +126,15 @@
|
||||||
</div>
|
</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>
|
<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: 'in', label: 'Money in', color: col.income(), values: inByM },
|
||||||
{ key: 'out', label: 'Money out', color: col.spend(), values: outByM }
|
{ key: 'out', label: 'Money out', color: col.spend(), values: outByM }
|
||||||
], { height: 230 });
|
], { height: 230, labelFn: granLabelFn() });
|
||||||
C.donut($('#ov-donut'), [
|
C.donut($('#ov-donut'), [
|
||||||
{ label: 'Real money', value: TOT.realIn + TOT.realOut, color: col.income() },
|
{ label: 'Real money', value: TOT.realIn + TOT.realOut, color: col.income() },
|
||||||
{ label: 'Internal shuffle', value: TOT.internal, color: col.internal() }
|
{ label: 'Internal shuffle', value: TOT.internal, color: col.internal() }
|
||||||
], { size: 176, centerTop: 'x' + mult.toFixed(1), centerBot: 'multiplier' });
|
], { 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
|
// ============================================================ TAB 2: SPENDING
|
||||||
|
|
@ -131,7 +147,7 @@
|
||||||
const palette = ['#ff5f73', '#ff7a6b', '#ff9460', '#ffae57', '#ffc24b', '#d98cff', '#8c9eff', '#6fd0ff', '#5fd0a0', '#7a8699'];
|
const palette = ['#ff5f73', '#ff7a6b', '#ff9460', '#ffae57', '#ffc24b', '#d98cff', '#8c9eff', '#6fd0ff', '#5fd0a0', '#7a8699'];
|
||||||
const catColor = i => i < palette.length ? palette[i] : col.mute();
|
const catColor = i => i < palette.length ? palette[i] : col.mute();
|
||||||
const months = window.MT.months;
|
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
|
// top merchants
|
||||||
const byMerch = groupSum(tx.filter(isExpense), t => t.description || 'Unknown');
|
const byMerch = groupSum(tx.filter(isExpense), t => t.description || 'Unknown');
|
||||||
const merch = sortEntries(byMerch).slice(0, 10);
|
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>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 class="card"><h3>Top merchants<span class="pill">single payees</span></h3><div class="mlist" id="sp-merch"></div></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 & merchants<span class="pill">click to expand</span></h3><div id="sp-breakdown"></div></div>
|
<div class="card" style="margin-top:16px;"><h3>Categories & merchants<span class="pill">click to expand</span></h3><div id="sp-breakdown"></div></div>
|
||||||
`;
|
`;
|
||||||
C.hbars($('#sp-cats'), cats.map(([label, d], i) => ({
|
C.hbars($('#sp-cats'), cats.map(([label, d], i) => ({
|
||||||
|
|
@ -164,7 +180,7 @@
|
||||||
row.addEventListener('mouseleave', () => window.MTtip.hide());
|
row.addEventListener('mouseleave', () => window.MTtip.hide());
|
||||||
ml.appendChild(row);
|
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);
|
renderCategoryBreakdown($('#sp-breakdown'), cats, catColor, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,7 +237,7 @@
|
||||||
const srcs = sortEntries(bySrc);
|
const srcs = sortEntries(bySrc);
|
||||||
const palette = ['#2ee6a6', '#3ad6b0', '#46c6ba', '#52b6c4', '#5ea6ce', '#b98cff', '#8c9eff', '#7a8699'];
|
const palette = ['#2ee6a6', '#3ad6b0', '#46c6ba', '#52b6c4', '#5ea6ce', '#b98cff', '#8c9eff', '#7a8699'];
|
||||||
const months = window.MT.months;
|
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 top3 = srcs.slice(0, 3).reduce((a, [, d]) => a + d.value, 0);
|
||||||
const conc = Math.round(top3 / total * 100);
|
const conc = Math.round(top3 / total * 100);
|
||||||
|
|
||||||
|
|
@ -238,7 +254,7 @@
|
||||||
<div class="chips" id="in-chips" style="margin-top:18px;"></div>
|
<div class="chips" id="in-chips" style="margin-top:18px;"></div>
|
||||||
</div>
|
</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) => ({
|
C.hbars($('#in-src'), srcs.map(([label, d], i) => ({
|
||||||
label, value: d.value, color: i < palette.length ? palette[i] : col.mute(),
|
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() });
|
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' });
|
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('');
|
$('#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
|
// ============================================================ TAB 4: CYCLES
|
||||||
|
|
@ -357,6 +373,17 @@
|
||||||
}
|
}
|
||||||
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => show(t.dataset.tab)));
|
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); });
|
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)
|
// expose for tweaks re-render (no-op until data is loaded)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue