Import Claude Design dashboard: 12-tab finance terminal

Full handoff from claude.ai/design bundle (4ESEfttPcJJzpoqjJxZozQ):
- dashboard.html: 12-tab shell (Overview, Spending, Income, People,
  Cards, Cycles, Balances, Rhythm, Platforms, Banks, Ledger, Quality)
- engine.js: data normalisation, categorisation, Sankey graph builder
- charts.js: reusable SVG primitives (bars, donuts, heatmaps, multiline)
- dashboard.js + dashboard2.js: all tab renderers
- styles.css + dashboard.css: Space Grotesk / IBM Plex Mono dark terminal
- sankey-view.js + orbit-view.js: Sankey and radial cycle views
- money-trace.html: Sankey / Orbit visualisation shell
- tweaks-panel.jsx: React palette/glow tweaks panel
This commit is contained in:
Kavi 2026-06-02 03:39:20 -04:00
parent 97ca5d6c10
commit a2cb7d3700
13 changed files with 3319 additions and 616 deletions

234
web/charts.js Normal file
View File

@ -0,0 +1,234 @@
/* money-trace · chart primitives (vanilla SVG, dark terminal aesthetic)
All charts share the CSS tokens from styles.css. window.Charts. */
(function () {
'use strict';
const NS = 'http://www.w3.org/2000/svg';
const el = (n, a, txt) => { const e = document.createElementNS(NS, n); for (const k in (a || {})) e.setAttribute(k, a[k]); if (txt != null) e.textContent = txt; return e; };
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const fmt = v => window.MT.CLPk(v);
const fmtFull = v => window.MT.CLP(v);
function clear(c) { while (c.firstChild) c.removeChild(c.firstChild); }
// ---------- horizontal bar list ----------
// data: [{label, value, color, sub}], opts:{max, money, onHover}
function hbars(container, data, opts) {
opts = opts || {};
clear(container);
const max = opts.max || Math.max(...data.map(d => d.value), 1);
const wrap = document.createElement('div'); wrap.className = 'hbars';
data.forEach(d => {
const row = document.createElement('div'); row.className = 'hbar-row';
const pct = (d.value / max) * 100;
row.innerHTML =
`<div class="hbar-label">${esc(d.label)}${d.sub ? `<span class="hbar-sub">${esc(d.sub)}</span>` : ''}</div>
<div class="hbar-track"><div class="hbar-fill" style="width:${pct.toFixed(1)}%;background:${d.color || cssv('--ink-mute')}"></div></div>
<div class="hbar-val num">${opts.money === false ? d.value : fmt(d.value)}</div>`;
if (opts.onHover) {
row.addEventListener('mousemove', e => opts.onHover(e, d));
row.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
}
wrap.appendChild(row);
});
container.appendChild(wrap);
}
// ---------- grouped monthly bars (income vs spend) ----------
// months:[ym], series:[{key,color,values:{ym:amount}}]
function monthlyBars(container, months, series, opts) {
opts = opts || {};
clear(container);
const W = container.clientWidth || 800, H = opts.height || 240;
const padL = 52, padR = 12, padT = 14, padB = 26;
const innerW = W - padL - padR, innerH = H - padT - padB;
const max = Math.max(1, ...series.flatMap(s => months.map(m => s.values[m] || 0)));
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', height: H });
// gridlines
const ticks = 4;
for (let i = 0; i <= ticks; i++) {
const y = padT + innerH * (1 - i / ticks);
svg.appendChild(el('line', { x1: padL, y1: y, x2: W - padR, y2: y, stroke: cssv('--line'), 'stroke-width': 1 }));
svg.appendChild(el('text', { x: padL - 8, y: y + 3, 'text-anchor': 'end', class: 'chart-axis' }, fmt(max * i / ticks)));
}
const groupW = innerW / months.length;
const n = series.length, gap = 3, bw = Math.min(22, (groupW * 0.62 - gap * (n - 1)) / n);
months.forEach((m, mi) => {
const gx = padL + groupW * mi + groupW / 2;
const totalW = bw * n + gap * (n - 1);
series.forEach((s, si) => {
const v = s.values[m] || 0;
const h = (v / max) * innerH;
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('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]));
});
container.appendChild(svg);
}
// ---------- line / area (cumulative net) ----------
function lineChart(container, months, valuesByMonth, opts) {
opts = opts || {};
clear(container);
const W = container.clientWidth || 800, H = opts.height || 200;
const padL = 56, padR = 14, padT = 14, padB = 26;
const innerW = W - padL - padR, innerH = H - padT - padB;
const vals = months.map(m => valuesByMonth[m] || 0);
let cum = 0; const pts = vals.map((v, i) => { cum += v; return cum; });
const maxV = Math.max(...pts, 0), minV = Math.min(...pts, 0);
const span = (maxV - minV) || 1;
const x = i => padL + (months.length === 1 ? innerW / 2 : innerW * i / (months.length - 1));
const y = v => padT + innerH * (1 - (v - minV) / span);
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', height: H });
// zero line
if (minV < 0) svg.appendChild(el('line', { x1: padL, y1: y(0), x2: W - padR, y2: y(0), stroke: cssv('--line-2'), 'stroke-dasharray': '3 3' }));
const ticks = 3;
for (let i = 0; i <= ticks; i++) {
const vv = minV + span * i / ticks;
svg.appendChild(el('text', { x: padL - 8, y: y(vv) + 3, 'text-anchor': 'end', class: 'chart-axis' }, fmt(vv)));
}
const col = opts.color || cssv('--c-income');
let dPath = '', aPath = '';
pts.forEach((v, i) => { const cmd = i === 0 ? 'M' : 'L'; dPath += `${cmd}${x(i)},${y(v)} `; });
aPath = dPath + `L${x(pts.length - 1)},${y(minV)} L${x(0)},${y(minV)} Z`;
const gid = 'g' + Math.random().toString(36).slice(2, 7);
const defs = el('defs');
const lg = el('linearGradient', { id: gid, x1: 0, y1: 0, x2: 0, y2: 1 });
lg.appendChild(el('stop', { offset: '0%', 'stop-color': col, 'stop-opacity': 0.28 }));
lg.appendChild(el('stop', { offset: '100%', 'stop-color': col, 'stop-opacity': 0 }));
defs.appendChild(lg); svg.appendChild(defs);
svg.appendChild(el('path', { d: aPath, fill: `url(#${gid})` }));
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('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]));
});
container.appendChild(svg);
}
// ---------- donut ----------
// data:[{label,value,color}]
function donut(container, data, opts) {
opts = opts || {};
clear(container);
const size = opts.size || 200, r = size / 2, ir = r * (opts.inner || 0.62);
const total = data.reduce((a, d) => a + d.value, 0) || 1;
const svg = el('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size });
let a0 = -Math.PI / 2;
data.forEach(d => {
const frac = d.value / total, a1 = a0 + frac * Math.PI * 2;
const large = (a1 - a0) > Math.PI ? 1 : 0;
const p = el('path', {
d: `M${r + Math.cos(a0) * r},${r + Math.sin(a0) * r} A${r},${r} 0 ${large} 1 ${r + Math.cos(a1) * r},${r + Math.sin(a1) * r} L${r + Math.cos(a1) * ir},${r + Math.sin(a1) * ir} A${ir},${ir} 0 ${large} 0 ${r + Math.cos(a0) * ir},${r + Math.sin(a0) * ir} Z`,
fill: d.color, opacity: 0.92, class: 'donut-seg'
});
p.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, d.label, fmtFull(d.value) + ' · ' + Math.round(frac * 100) + '%', d.color));
p.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
svg.appendChild(p);
a0 = a1;
});
if (opts.centerTop || opts.centerBot) {
svg.appendChild(el('text', { x: r, y: r - 2, 'text-anchor': 'middle', class: 'donut-center-top' }, opts.centerTop || ''));
svg.appendChild(el('text', { x: r, y: r + 16, 'text-anchor': 'middle', class: 'donut-center-bot' }, opts.centerBot || ''));
}
container.appendChild(svg);
}
const esc = s => String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
// ---------- multi-line (balance trajectories over a date domain) ----------
// series:[{label,color,points:[{t:Date|ms, v}]}], opts:{height, money}
function multiLine(container, series, opts) {
opts = opts || {};
clear(container);
const W = container.clientWidth || 800, H = opts.height || 280;
const padL = 60, padR = 16, padT = 16, padB = 28;
const innerW = W - padL - padR, innerH = H - padT - padB;
const all = series.flatMap(s => s.points);
if (!all.length) { container.innerHTML = '<div style="color:var(--ink-dim);padding:40px;text-align:center;font-size:13px;">No balance data</div>'; return; }
const ts = all.map(p => +p.t);
const t0 = Math.min(...ts), t1 = Math.max(...ts);
let vMax = Math.max(...all.map(p => p.v)), vMin = Math.min(...all.map(p => p.v), 0);
const vspan = (vMax - vMin) || 1;
const x = t => padL + (t1 === t0 ? innerW / 2 : innerW * (+t - t0) / (t1 - t0));
const y = v => padT + innerH * (1 - (v - vMin) / vspan);
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', height: H });
// gridlines + y labels
const ticks = 4;
for (let i = 0; i <= ticks; i++) {
const vv = vMin + vspan * i / ticks, yy = y(vv);
svg.appendChild(el('line', { x1: padL, y1: yy, x2: W - padR, y2: yy, stroke: cssv('--line'), 'stroke-width': 1 }));
svg.appendChild(el('text', { x: padL - 8, y: yy + 3, 'text-anchor': 'end', class: 'chart-axis' }, fmt(vv)));
}
if (vMin < 0) svg.appendChild(el('line', { x1: padL, y1: y(0), x2: W - padR, y2: y(0), stroke: cssv('--c-spend'), 'stroke-opacity': 0.4, 'stroke-dasharray': '4 3' }));
// x labels (month ticks)
const months = [];
let cur = new Date(t0); cur.setDate(1);
while (+cur <= t1) { months.push(new Date(cur)); cur.setMonth(cur.getMonth() + 1); }
const xstep = Math.ceil(months.length / 8);
months.forEach((m, i) => { if (i % xstep === 0) svg.appendChild(el('text', { x: x(+m), y: H - 9, 'text-anchor': 'middle', class: 'chart-axis' }, window.MT.MONTH_LABEL(m.toISOString().slice(0, 7)).split(" '")[0])); });
// lines
series.forEach(s => {
const pts = s.points.slice().sort((a, b) => +a.t - +b.t);
if (!pts.length) return;
let dPath = '';
pts.forEach((p, i) => { dPath += `${i === 0 ? 'M' : 'L'}${x(+p.t)},${y(p.v)} `; });
svg.appendChild(el('path', { d: dPath, fill: 'none', stroke: s.color, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round', opacity: 0.9 }));
// endpoint dot
const last = pts[pts.length - 1];
svg.appendChild(el('circle', { cx: x(+last.t), cy: y(last.v), r: 3, fill: s.color }));
// hover hit-dots
pts.forEach(p => {
const dot = el('circle', { cx: x(+p.t), cy: y(p.v), r: 7, fill: 'transparent', style: 'cursor:pointer' });
dot.addEventListener('mousemove', e => window.MTtip && window.MTtip.raw(e, s.label, new Date(+p.t).toISOString().slice(0, 10) + ' · ' + fmtFull(p.v), s.color));
dot.addEventListener('mouseleave', () => window.MTtip && window.MTtip.hide());
svg.appendChild(dot);
});
});
container.appendChild(svg);
}
// ---------- heatmap grid ----------
// rows:[{label, cells:[{v, raw, title}]}], colLabels:[], opts:{color, max}
function heatmap(container, rows, colLabels, opts) {
opts = opts || {};
clear(container);
const max = opts.max || Math.max(1, ...rows.flatMap(r => r.cells.map(c => c.v)));
const base = opts.color || cssv('--c-income');
const wrap = document.createElement('div'); wrap.className = 'heatmap';
// header
const head = document.createElement('div'); head.className = 'hm-row hm-head';
head.innerHTML = `<div class="hm-rowlab"></div>` + colLabels.map(c => `<div class="hm-collab">${esc(c)}</div>`).join('');
wrap.appendChild(head);
rows.forEach(r => {
const row = document.createElement('div'); row.className = 'hm-row';
let html = `<div class="hm-rowlab">${esc(r.label)}</div>`;
r.cells.forEach(c => {
const alpha = c.v <= 0 ? 0 : 0.12 + 0.88 * Math.pow(c.v / max, 0.6);
html += `<div class="hm-cell" data-v="${c.v}" title="${esc(c.title || '')}" style="--a:${alpha.toFixed(3)};--hc:${base}"></div>`;
});
row.innerHTML = html;
wrap.appendChild(row);
});
container.appendChild(wrap);
if (opts.onHover) wrap.querySelectorAll('.hm-cell').forEach((cell, i) => { });
// attach hover via cell data
let idx = 0;
rows.forEach(r => r.cells.forEach(c => {
const cell = wrap.querySelectorAll('.hm-cell')[idx++];
if (c.raw && c.raw.length && window.MTtip) {
cell.addEventListener('mousemove', e => window.MTtip.rows(e, c.title, base, c.raw));
cell.addEventListener('mouseleave', () => window.MTtip.hide());
}
}));
}
window.Charts = { hbars, monthlyBars, lineChart, donut, multiLine, heatmap, clear, esc };
})();

195
web/dashboard.css Normal file
View File

@ -0,0 +1,195 @@
/* money-trace · dashboard layout & charts */
.dash-body { overflow-y: auto; overflow-x: hidden; }
/* tab nav */
.tabs {
display: flex; gap: 2px; padding: 0 22px; border-bottom: 1px solid var(--line);
background: var(--panel); position: sticky; top: 0; z-index: 20;
overflow-x: auto; scrollbar-width: thin;
}
.tabs::-webkit-scrollbar { height: 4px; }
.tabs::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 2px; }
.tab {
font-family: var(--font-ui); font-size: 13.5px; color: var(--ink-mute);
background: transparent; border: 0; border-bottom: 2px solid transparent;
padding: 15px 18px 13px; cursor: pointer; display: flex; align-items: center; gap: 9px;
transition: .16s; white-space: nowrap;
}
.tab:hover { color: var(--ink); }
.tab.on { color: var(--ink); border-bottom-color: var(--c-income); }
.tab svg { width: 16px; height: 16px; opacity: .85; }
.tab .tnum { font-family: var(--font-num); font-size: 10px; color: var(--ink-dim); }
/* panels */
.panel-wrap { padding: 22px; max-width: 1320px; margin: 0 auto; }
.panel { display: none; animation: fade .22s ease; }
.panel.on { display: block; }
@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.panel-head { margin-bottom: 18px; }
.panel-head h2 { font-size: 22px; font-weight: 600; margin: 0 0 5px; letter-spacing: -0.01em; }
.panel-head p { font-size: 13.5px; color: var(--ink-mute); margin: 0; max-width: 640px; line-height: 1.5; }
.panel-head p b { color: var(--ink); font-weight: 500; }
/* grid */
.grid { display: grid; gap: 16px; }
.g-2 { grid-template-columns: 1fr 1fr; }
.g-3 { grid-template-columns: repeat(3, 1fr); }
.g-1-2 { grid-template-columns: 320px 1fr; }
.g-2-1 { grid-template-columns: 1fr 340px; }
@media (max-width: 1080px) { .g-2, .g-3, .g-1-2, .g-2-1 { grid-template-columns: 1fr; } }
/* card */
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; }
.card.tall { min-height: 280px; }
.card h3 { font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-mute); margin: 0 0 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.card h3 .pill { margin-left: auto; font-family: var(--font-num); font-size: 11px; color: var(--ink-dim); text-transform: none; letter-spacing: 0; }
.card .chart-host { width: 100%; }
/* KPI cards */
.kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px 20px; position: relative; overflow: hidden; }
.kpi::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent, var(--ink-mute)); }
.kpi .k-lab { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-mute); margin-bottom: 10px; }
.kpi .k-val { font-size: 30px; font-weight: 500; line-height: 1; font-family: var(--font-num); letter-spacing: -0.01em; }
.kpi .k-val small { font-size: 15px; color: var(--ink-dim); }
.kpi .k-sub { font-size: 11.5px; color: var(--ink-dim); margin-top: 8px; }
.kpi .k-sub b { font-family: var(--font-num); color: var(--ink-mute); font-weight: 500; }
/* hbars */
.hbars { display: flex; flex-direction: column; gap: 11px; }
.hbar-row { display: grid; grid-template-columns: 168px 1fr 96px; align-items: center; gap: 14px; cursor: default; }
.hbar-label { font-size: 13px; color: var(--ink); display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
.hbar-label .hbar-sub { font-size: 10.5px; color: var(--ink-dim); font-family: var(--font-num); }
.hbar-track { height: 22px; background: var(--bg); border-radius: 5px; overflow: hidden; }
.hbar-fill { height: 100%; border-radius: 5px; opacity: 0.9; transition: width .5s cubic-bezier(.3,1,.4,1); }
.hbar-val { text-align: right; font-size: 13px; color: var(--ink); }
.hbar-row:hover .hbar-fill { opacity: 1; }
/* legend chips */
.chips { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 14px; }
.chip { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--ink-mute); }
.chip i { width: 10px; height: 10px; border-radius: 3px; }
.chip b { color: var(--ink); font-family: var(--font-num); font-weight: 500; }
/* chart axis text */
.chart-axis { fill: var(--ink-dim); font-size: 10px; font-family: var(--font-num); }
.donut-center-top { fill: var(--ink); font-size: 20px; font-family: var(--font-num); font-weight: 500; }
.donut-center-bot { fill: var(--ink-dim); font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-ui); }
.donut-wrap { display: flex; align-items: center; gap: 22px; }
/* big callout */
.callout { display: flex; align-items: baseline; gap: 14px; padding: 4px 0; }
.callout .big { font-size: 54px; font-weight: 600; font-family: var(--font-num); line-height: 1; letter-spacing: -0.02em; }
.callout .big.mult { color: var(--c-fee); }
.callout .ctxt { font-size: 13px; color: var(--ink-mute); line-height: 1.5; }
.callout .ctxt b { color: var(--ink); }
/* bank table */
.btable { width: 100%; border-collapse: collapse; font-size: 13px; }
.btable th { text-align: right; font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-dim); font-weight: 600; padding: 0 14px 12px; border-bottom: 1px solid var(--line); }
.btable th:first-child { text-align: left; }
.btable td { padding: 13px 14px; border-bottom: 1px solid var(--line); text-align: right; font-family: var(--font-num); color: var(--ink); }
.btable td:first-child { text-align: left; font-family: var(--font-ui); }
.btable tr:hover td { background: rgba(255,255,255,0.02); }
.btable .bk-name { display: flex; align-items: center; gap: 10px; }
.btable .bk-dot { width: 9px; height: 9px; border-radius: 3px; flex: none; }
.btable .bk-meta { font-size: 10.5px; color: var(--ink-dim); font-family: var(--font-num); }
.btable .minibar { display: inline-flex; height: 7px; border-radius: 3px; overflow: hidden; width: 120px; background: var(--bg); vertical-align: middle; }
.btable .minibar i { height: 100%; display: block; }
.btable td.muted { color: var(--ink-dim); }
/* insight strip */
.insight { display: flex; gap: 12px; align-items: flex-start; background: var(--panel-2); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; font-size: 13px; color: var(--ink-mute); line-height: 1.55; }
.insight .i-ic { width: 26px; height: 26px; border-radius: 7px; display: grid; place-items: center; flex: none; background: var(--bg); color: var(--c-income); }
.insight b { color: var(--ink); font-weight: 500; }
.insight .num { color: var(--ink); }
/* month mini selector for panels */
.mini-months { display: flex; gap: 3px; align-items: center; }
.mini-months .mm { font-family: var(--font-num); font-size: 11px; color: var(--ink-dim); padding: 4px 8px; border-radius: 6px; cursor: pointer; }
.mini-months .mm:hover { color: var(--ink); background: var(--bg); }
.mini-months .mm.on { color: var(--bg); background: var(--c-income); }
/* top merchants list */
.mlist { display: flex; flex-direction: column; }
.mrow { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); }
.mrow:last-child { border-bottom: 0; }
.mrow .m-rank { font-family: var(--font-num); font-size: 11px; color: var(--ink-dim); width: 20px; }
.mrow .m-name { flex: 1; font-size: 13px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mrow .m-cat { font-size: 10.5px; color: var(--ink-dim); margin-left: 6px; }
.mrow .m-amt { font-family: var(--font-num); font-size: 13px; color: var(--ink); }
.mrow .m-cnt { font-family: var(--font-num); font-size: 10.5px; color: var(--ink-dim); width: 48px; text-align: right; }
/* ---- heatmap ---- */
.heatmap { display: flex; flex-direction: column; gap: 4px; }
.hm-row { display: grid; grid-template-columns: 64px repeat(var(--cols, 31), 1fr); gap: 4px; align-items: center; }
.hm-head { margin-bottom: 2px; }
.hm-rowlab { font-size: 11.5px; color: var(--ink-mute); text-align: right; padding-right: 8px; font-family: var(--font-ui); }
.hm-collab { font-size: 9px; color: var(--ink-dim); text-align: center; font-family: var(--font-num); }
.hm-cell { aspect-ratio: 1; border-radius: 3px; background: color-mix(in oklab, var(--hc) calc(var(--a) * 100%), var(--bg)); border: 1px solid var(--line); transition: transform .1s; min-height: 14px; }
.hm-cell[data-v="0"] { background: var(--bg); }
.hm-cell:hover { transform: scale(1.18); border-color: var(--line-2); cursor: default; }
.hm-scale { display: flex; align-items: center; gap: 8px; font-size: 10.5px; color: var(--ink-dim); margin-top: 12px; justify-content: flex-end; }
.hm-scale .sw { display: flex; gap: 3px; }
.hm-scale .sw i { width: 14px; height: 14px; border-radius: 3px; }
/* ---- account picker chips ---- */
.acct-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.acct-chip { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); cursor: pointer; font-size: 12.5px; color: var(--ink-mute); transition: .14s; }
.acct-chip:hover { border-color: var(--line-2); color: var(--ink); }
.acct-chip.on { color: var(--ink); background: var(--panel-2); }
.acct-chip i { width: 9px; height: 9px; border-radius: 3px; }
.acct-chip .ac-end { font-family: var(--font-num); font-size: 11px; color: var(--ink-dim); }
/* ---- ledger explorer table ---- */
.lx-controls { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; }
.lx-search { flex: 1; min-width: 220px; position: relative; }
.lx-search input { width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 9px; padding: 11px 14px 11px 38px; color: var(--ink); font-family: var(--font-ui); font-size: 13.5px; }
.lx-search input:focus { outline: none; border-color: var(--line-2); }
.lx-search svg { position: absolute; left: 12px; top: 11px; width: 16px; height: 16px; color: var(--ink-dim); }
.lx-select { background: var(--bg); border: 1px solid var(--line); border-radius: 9px; padding: 11px 13px; color: var(--ink); font-family: var(--font-ui); font-size: 13px; cursor: pointer; }
.lx-count { font-size: 12px; color: var(--ink-dim); font-family: var(--font-num); white-space: nowrap; }
.lx-table-wrap { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.lx-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.lx-table thead { position: sticky; top: 0; background: var(--panel-2); z-index: 2; }
.lx-table th { text-align: left; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-dim); font-weight: 600; padding: 11px 14px; border-bottom: 1px solid var(--line); cursor: pointer; user-select: none; white-space: nowrap; }
.lx-table th:hover { color: var(--ink-mute); }
.lx-table th.r, .lx-table td.r { text-align: right; }
.lx-table td { padding: 10px 14px; border-bottom: 1px solid var(--line); color: var(--ink-mute); }
.lx-table tr:last-child td { border-bottom: 0; }
.lx-table tr:hover td { background: rgba(255,255,255,0.02); }
.lx-table .lx-date { font-family: var(--font-num); font-size: 11.5px; color: var(--ink-dim); white-space: nowrap; }
.lx-table .lx-desc { color: var(--ink); max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lx-table .lx-amt { font-family: var(--font-num); white-space: nowrap; }
.lx-table .lx-amt.cr { color: var(--c-income); }
.lx-table .lx-amt.db { color: var(--ink); }
.lx-flow-tag { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; padding: 2px 8px; border-radius: 20px; border: 1px solid var(--line-2); color: var(--ink-mute); white-space: nowrap; }
.lx-flow-tag i { width: 6px; height: 6px; border-radius: 50%; }
.lx-pdf { color: var(--ink-dim); font-family: var(--font-num); font-size: 10.5px; }
.lx-more { text-align: center; padding: 14px; color: var(--ink-dim); font-size: 12px; cursor: pointer; }
.lx-more:hover { color: var(--ink); background: rgba(255,255,255,0.02); }
/* ---- coverage grid (data quality) ---- */
.cov-grid { overflow-x: auto; }
.cov-row { display: grid; grid-template-columns: 190px repeat(var(--cols, 15), 1fr); gap: 3px; align-items: center; margin-bottom: 3px; }
.cov-head .cov-cell { background: transparent; }
.cov-rowlab { font-size: 11.5px; color: var(--ink); display: flex; align-items: center; gap: 8px; overflow: hidden; }
.cov-rowlab .cov-dot { width: 8px; height: 8px; border-radius: 3px; flex: none; }
.cov-rowlab .cov-meta { font-size: 9.5px; color: var(--ink-dim); font-family: var(--font-num); }
.cov-collab { font-size: 9px; color: var(--ink-dim); text-align: center; font-family: var(--font-num); writing-mode: vertical-rl; height: 36px; }
.cov-cell { height: 22px; border-radius: 3px; background: var(--bg); border: 1px solid var(--line); position: relative; }
.cov-cell.has { background: color-mix(in oklab, var(--c-income) 30%, var(--bg)); border-color: color-mix(in oklab, var(--c-income) 45%, var(--bg)); }
.cov-cell.has:hover { background: color-mix(in oklab, var(--c-income) 55%, var(--bg)); }
/* ---- mini stat row ---- */
.qstats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 16px; }
.qstat { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 15px 16px; }
.qstat .qv { font-family: var(--font-num); font-size: 24px; color: var(--ink); }
.qstat .ql { font-size: 11px; color: var(--ink-mute); margin-top: 5px; }
.qbar { height: 8px; border-radius: 4px; background: var(--bg); overflow: hidden; margin-top: 10px; display: flex; }
.qbar i { height: 100%; }
@media (max-width: 1080px) { .qstats { grid-template-columns: 1fr 1fr; } }
/* net pill */
.net-pos { color: var(--c-income); }
.net-neg { color: var(--c-spend); }

View File

@ -1,637 +1,190 @@
<!doctype html>
<html lang="es">
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>money-trace · dashboard</title>
<style>
:root {
--bg: #0d0d12;
--surface: #14141c;
--raised: #1c1c28;
--border: #242433;
--border2: #2e2e44;
--txt: #e2e2f0;
--sub: #7070a0;
--dim: #4a4a70;
--in: #4ade9a;
--in-dim: #1a3d2a;
--out: #f06a6a;
--out-dim: #3d1a1a;
--acc: #a78bfa;
--acc-dim: #2a1f4a;
--gold: #f0b84a;
--gold-dim:#3d2e0f;
--blue: #60b0f0;
--sidebar: 56px;
--topbar: 52px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0 }
body { background: var(--bg); color: var(--txt); font: 13px/1.5 ui-sans-serif, -apple-system, Segoe UI, sans-serif; height: 100vh; display: flex; overflow: hidden }
/* ── sidebar ──────────────────────────────── */
nav {
width: var(--sidebar); height: 100vh; background: var(--surface);
border-right: 1px solid var(--border); display: flex; flex-direction: column;
align-items: center; padding: 12px 0; gap: 4px; flex-shrink: 0; z-index: 10;
}
.logo { width: 32px; height: 32px; border-radius: 9px; background: var(--acc-dim);
display: flex; align-items: center; justify-content: center; margin-bottom: 12px;
font-size: 15px; font-weight: 800; color: var(--acc); letter-spacing: -1px }
.nav-btn { width: 36px; height: 36px; border-radius: 8px; border: none; background: transparent;
color: var(--dim); cursor: pointer; display: flex; align-items: center; justify-content: center;
font-size: 16px; transition: background .15s, color .15s }
.nav-btn:hover { background: var(--raised); color: var(--txt) }
.nav-btn.active { background: var(--acc-dim); color: var(--acc) }
.nav-sep { width: 24px; height: 1px; background: var(--border); margin: 4px 0 }
.nav-bot { margin-top: auto }
/* ── main frame ───────────────────────────── */
.frame { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0 }
/* ── topbar ───────────────────────────────── */
.topbar {
height: var(--topbar); background: var(--surface); border-bottom: 1px solid var(--border);
display: flex; align-items: center; padding: 0 20px; gap: 14px; flex-shrink: 0
}
.topbar h1 { font-size: 13px; font-weight: 600; color: var(--txt); letter-spacing: .2px }
.topbar h1 span { color: var(--sub); font-weight: 400 }
.status-pill { display: flex; align-items: center; gap: 5px; background: var(--raised);
border: 1px solid var(--border); border-radius: 20px; padding: 3px 10px 3px 6px; font-size: 11px; color: var(--sub) }
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--dim) }
.dot.live { background: var(--in); box-shadow: 0 0 6px var(--in) }
.dot.err { background: var(--out) }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px }
.api-input { background: var(--raised); border: 1px solid var(--border); color: var(--txt);
padding: 5px 10px; border-radius: 8px; font-size: 12px; outline: none; width: 190px }
.api-input:focus { border-color: var(--acc) }
.icon-btn { background: var(--raised); border: 1px solid var(--border); color: var(--sub);
padding: 5px 10px; border-radius: 8px; font-size: 12px; cursor: pointer }
.icon-btn:hover { color: var(--txt); border-color: var(--border2) }
/* ── scroll area ──────────────────────────── */
.scroll { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 20px }
.scroll::-webkit-scrollbar { width: 5px }
.scroll::-webkit-scrollbar-track { background: transparent }
.scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px }
/* ── error bar ────────────────────────────── */
#err { display: none; background: var(--out-dim); border: 1px solid #5a2020; color: #f09090;
border-radius: 8px; padding: 9px 14px; margin-bottom: 16px; font-size: 12px }
/* ── KPI row ──────────────────────────────── */
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 18px }
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; position: relative; overflow: hidden }
.kpi::before { content: ''; position: absolute; inset: 0; opacity: .06; border-radius: 12px }
.kpi.green::before { background: var(--in) } .kpi.green .kpi-val { color: var(--in) }
.kpi.red::before { background: var(--out) } .kpi.red .kpi-val { color: var(--out) }
.kpi.purple::before{ background: var(--acc) } .kpi.purple .kpi-val { color: var(--acc) }
.kpi.gold::before { background: var(--gold) } .kpi.gold .kpi-val { color: var(--gold) }
.kpi-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .7px; color: var(--sub); margin-bottom: 6px }
.kpi-val { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1 }
.kpi-sub { font-size: 11px; color: var(--dim); margin-top: 4px }
/* ── two-col body ─────────────────────────── */
.body-grid { display: grid; grid-template-columns: 1fr 340px; gap: 14px; margin-bottom: 14px }
@media (max-width: 960px) { .body-grid { grid-template-columns: 1fr } }
/* ── panel ────────────────────────────────── */
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden }
.panel-head { padding: 13px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between }
.panel-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .7px; color: var(--sub) }
.panel-count { font-size: 11px; color: var(--dim) }
/* ── type chart ───────────────────────────── */
.chart-wrap { padding: 16px; display: flex; gap: 20px; align-items: center }
.donut-wrap { flex-shrink: 0; position: relative; width: 120px; height: 120px }
.donut-wrap svg { overflow: visible }
.donut-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none }
.donut-center .dc-val { font-size: 20px; font-weight: 700; color: var(--txt) }
.donut-center .dc-sub { font-size: 10px; color: var(--sub) }
.donut-legend { flex: 1; display: flex; flex-direction: column; gap: 7px; min-width: 0 }
.legend-row { display: flex; align-items: center; gap: 8px }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0 }
.legend-label { font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.legend-amt { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--sub); white-space: nowrap }
.legend-pct { font-size: 10px; color: var(--dim); width: 32px; text-align: right; flex-shrink: 0 }
/* ── entity cards ─────────────────────────── */
.entity-list { padding: 8px 0 }
.entity-row { padding: 10px 16px; display: flex; align-items: center; gap: 11px; border-bottom: 1px solid var(--border) }
.entity-row:last-child { border-bottom: 0 }
.avatar { width: 32px; height: 32px; border-radius: 9px; background: var(--acc-dim);
display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--acc); flex-shrink: 0 }
.avatar.company { background: var(--gold-dim); color: var(--gold) }
.avatar.vendor { background: var(--raised); color: var(--sub) }
.entity-info { flex: 1; min-width: 0 }
.entity-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.entity-meta { font-size: 11px; color: var(--sub); margin-top: 1px }
.entity-accts { margin-top: 5px; display: flex; flex-direction: column; gap: 3px }
.acct-chip { font-size: 11px; color: var(--dim); display: flex; align-items: center; gap: 5px }
.acct-chip::before { content: ''; width: 3px; height: 3px; border-radius: 50%; background: var(--border2); flex-shrink: 0 }
.entity-net { font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0 }
/* ── movements ────────────────────────────── */
.mov-controls { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; flex-wrap: wrap; align-items: center }
.ctrl-input { background: var(--raised); border: 1px solid var(--border); color: var(--txt);
padding: 6px 10px; border-radius: 8px; font-size: 12px; outline: none }
.ctrl-input:focus { border-color: var(--acc) }
.ctrl-input::placeholder { color: var(--dim) }
.seg { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden }
.seg button { background: transparent; border: 0; color: var(--sub); padding: 6px 11px; cursor: pointer; font-size: 12px }
.seg button.on { background: var(--raised); color: var(--txt) }
/* ── movement feed ────────────────────────── */
.mov-feed { display: flex; flex-direction: column }
.mov-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px;
border-bottom: 1px solid var(--border); cursor: pointer; transition: background .1s }
.mov-item:last-child { border-bottom: 0 }
.mov-item:hover { background: var(--raised) }
.mov-item.selected { background: #1e1e35 }
.mov-icon { width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center;
justify-content: center; font-size: 14px; flex-shrink: 0; margin-top: 1px }
.mov-icon.in { background: var(--in-dim); color: var(--in) }
.mov-icon.out { background: var(--out-dim); color: var(--out) }
.mov-body { flex: 1; min-width: 0 }
.mov-top { display: flex; align-items: baseline; gap: 8px }
.mov-desc { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0 }
.mov-amt { font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0 }
.mov-amt.in { color: var(--in) }
.mov-amt.out { color: var(--out) }
.mov-bot { display: flex; align-items: center; gap: 8px; margin-top: 3px }
.mov-date { font-size: 11px; color: var(--dim) }
.mov-cpty { font-size: 11px; color: var(--sub); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.etype-chip { font-size: 10px; padding: 1px 6px; border-radius: 4px; white-space: nowrap; flex-shrink: 0 }
.tree-trigger { flex-shrink: 0; background: var(--raised); border: 1px solid var(--border);
color: var(--dim); padding: 4px 9px; border-radius: 6px; font-size: 11px; cursor: pointer;
margin-top: 1px; transition: border-color .15s, color .15s }
.tree-trigger:hover { border-color: var(--acc); color: var(--acc) }
/* ── empty / loading ──────────────────────── */
.empty { color: var(--sub); padding: 28px; text-align: center; font-size: 12px }
.spin { display: inline-block; width: 11px; height: 11px; border: 2px solid var(--border2);
border-top-color: var(--acc); border-radius: 50%; animation: sp .6s linear infinite; vertical-align: middle; margin-right: 5px }
@keyframes sp { to { transform: rotate(360deg) } }
/* ── tree drawer ──────────────────────────── */
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 400px; max-width: 90vw;
background: var(--surface); border-left: 1px solid var(--border);
display: flex; flex-direction: column; z-index: 100;
transform: translateX(100%); transition: transform .22s cubic-bezier(.4,0,.2,1) }
.drawer.open { transform: translateX(0) }
.drawer-head { padding: 14px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; flex-shrink: 0 }
.drawer-head h3 { font-size: 13px; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.close-btn { background: var(--raised); border: 1px solid var(--border); color: var(--sub);
width: 28px; height: 28px; border-radius: 7px; cursor: pointer; font-size: 13px; display: flex; align-items: center; justify-content: center }
.close-btn:hover { color: var(--txt) }
.drawer-tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0 }
.drawer-tabs button { flex: 1; background: transparent; border: 0; border-bottom: 2px solid transparent;
color: var(--sub); padding: 9px; font-size: 12px; cursor: pointer }
.drawer-tabs button.on { color: var(--txt); border-bottom-color: var(--acc) }
.drawer-body { flex: 1; overflow-y: auto; padding: 14px; font: 12px/1.6 ui-monospace, monospace }
/* ── tree nodes ───────────────────────────── */
.tn { margin: 3px 0 }
.tn-label { display: flex; align-items: flex-start; gap: 6px; padding: 3px 6px; border-radius: 6px }
.tn-label:hover { background: var(--raised) }
.tn-kind { font-size: 9px; background: var(--raised); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; color: var(--dim); flex-shrink: 0; margin-top: 2px }
.tn-id { color: var(--dim); font-size: 10px }
.tn-desc { color: var(--txt) }
.tn-amt { color: var(--acc); font-size: 11px; white-space: nowrap; flex-shrink: 0 }
.tn-date { color: var(--dim); font-size: 10px; flex-shrink: 0 }
.tl-label { color: var(--sub); font-size: 10px; padding: 1px 6px 1px 20px }
.tl-label .tl-type { color: var(--blue) }
.tl-label .tl-meth { color: var(--dim) }
.tc { border-left: 1px solid var(--border); margin-left: 12px; padding-left: 10px }
.t-cycle { color: var(--gold); font-size: 11px; font-style: italic }
</style>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Money Trace · Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="dashboard.css" />
</head>
<body>
<div id="loading">reconstructing money flow…</div>
<!-- sidebar -->
<nav>
<div class="logo"></div>
<button class="nav-btn active" title="Dashboard"></button>
<a href="index.html" style="text-decoration:none"><button class="nav-btn" title="Cartolas"></button></a>
<div class="nav-sep"></div>
<div class="nav-bot">
<div class="dot" id="nav-dot" title="Estado del servidor"></div>
</div>
</nav>
<!-- main -->
<div class="frame">
<!-- topbar -->
<div class="topbar">
<h1>money&#8209;trace <span id="sub-label">· cargando…</span></h1>
<div class="status-pill">
<div class="dot" id="status-dot"></div>
<span id="status-txt">conectando</span>
<div id="app" style="display:none; flex-direction:column; height:100vh;">
<header class="bar">
<div class="brand">
<div class="mark">money<b>·</b>trace</div>
<div class="sub">725 txns · 12 banks · 10 months</div>
</div>
<div class="topbar-right">
<input class="api-input" id="api-base" value="http://localhost:3910" placeholder="http://localhost:3910">
<button class="icon-btn" id="reload-btn">↺ Recargar</button>
<div class="stats">
<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>
Flow view
</button></div>
</a>
</div>
</div>
</header>
<!-- scroll area -->
<div class="scroll">
<div id="err"></div>
<!-- KPIs -->
<div class="kpi-row" id="kpi-row">
<div class="kpi green"><div class="kpi-label">Ingresos reales</div><div class="kpi-val" id="k-income"></div><div class="kpi-sub" id="k-income-c"> movimientos</div></div>
<div class="kpi red"> <div class="kpi-label">Egresos reales</div> <div class="kpi-val" id="k-expense"></div><div class="kpi-sub" id="k-expense-c"> movimientos</div></div>
<div class="kpi purple"><div class="kpi-label">Neto</div> <div class="kpi-val" id="k-net"></div> <div class="kpi-sub">ingresos egresos</div></div>
<div class="kpi"> <div class="kpi-label">Movimientos</div> <div class="kpi-val" id="k-mov"></div> <div class="kpi-sub" id="k-mov-sub"> cuentas</div></div>
<div class="kpi"> <div class="kpi-label">Links de trazab.</div><div class="kpi-val" id="k-links"></div><div class="kpi-sub" id="k-links-sub"> documentos</div></div>
</div>
<!-- body grid -->
<div class="body-grid">
<!-- left: type donut + movements -->
<div style="display:flex;flex-direction:column;gap:14px">
<div class="panel">
<div class="panel-head">
<div class="panel-title">Flujo por tipo económico</div>
<div class="panel-count" id="type-cur">CLP</div>
</div>
<div class="chart-wrap" id="chart-wrap">
<div class="empty"><span class="spin"></span>cargando…</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<div class="panel-title">Movimientos</div>
<div style="display:flex;gap:6px">
<div class="seg" id="dir-seg">
<button data-d="all" class="on">Todo</button>
<button data-d="in"></button>
<button data-d="out"></button>
</div>
<select id="etype-sel" class="ctrl-input" style="padding:5px 8px">
<option value="">Todos los tipos</option>
</select>
<input class="ctrl-input" id="mov-q" placeholder="Buscar…" style="width:150px">
</div>
</div>
<div class="mov-feed" id="mov-feed">
<div class="empty"><span class="spin"></span>cargando…</div>
</div>
<div id="mov-empty" class="empty" hidden>Sin movimientos para este filtro.</div>
</div>
<nav class="tabs" id="tabs">
<button class="tab on" data-tab="overview">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
Overview
</button>
<button class="tab" data-tab="spending">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="M7 14l3-4 3 3 5-7"/></svg>
Where it goes
</button>
<button class="tab" data-tab="income">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
Where it comes from
</button>
<button class="tab" data-tab="people">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6"/><path d="M16 5.5a3 3 0 0 1 0 5.5M21 20c0-2.5-1.5-4.6-3.7-5.5"/></svg>
People
</button>
<button class="tab" data-tab="cards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>
Cards &amp; credit
</button>
<button class="tab" data-tab="cycles">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v5h-5"/></svg>
The cycles
</button>
<button class="tab" data-tab="balances">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="M6 14l4-5 3 3 5-7"/></svg>
Balances
</button>
<button class="tab" data-tab="rhythm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="17" rx="2"/><path d="M3 9h18M8 2v4M16 2v4"/></svg>
Rhythm
</button>
<button class="tab" data-tab="platforms">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M6 9v3a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V9M12 14v1"/></svg>
Platforms
</button>
<button class="tab" data-tab="banks">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M4 10h16M5 10V7l7-4 7 4v3M8 10v8M12 10v8M16 10v8"/></svg>
Banks
</button>
<button class="tab" data-tab="ledger">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"/><path d="M4 9h16M4 14h16M9 4v16"/></svg>
Ledger
</button>
<button class="tab" data-tab="quality">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 12l2 2 4-4"/><path d="M12 3l8 4v5c0 4.4-3.1 8.3-8 9-4.9-.7-8-4.6-8-9V7z"/></svg>
Data quality
</button>
</nav>
<div class="view dash-body" style="flex:1;">
<div class="panel-wrap">
<div class="panel on" id="panel-overview">
<div class="panel-head"><h2>The big picture</h2><p id="ov-intro">Across these statements, real money came in and went out — but the ledger logged far more movement than that. Here's the real story versus the noise.</p></div>
<div id="host-overview"></div>
</div>
<!-- right: entities -->
<div class="panel" style="align-self:start">
<div class="panel-head">
<div class="panel-title">Entidades</div>
<div class="panel-count" id="ent-count"></div>
</div>
<div class="entity-list" id="entity-list">
<div class="empty"><span class="spin"></span>cargando…</div>
</div>
<div class="panel" id="panel-spending">
<div class="panel-head"><h2>What am I spending my money on, at the end of the day?</h2><p>Every purchase and fee, grouped by what it actually was. Merchant names aren't in the raw statements, so categories are inferred from transaction descriptions.</p></div>
<div id="host-spending"></div>
</div>
</div><!-- /body-grid -->
</div><!-- /scroll -->
</div><!-- /frame -->
<!-- tree drawer -->
<div class="drawer" id="drawer">
<div class="drawer-head">
<h3 id="drawer-title">Árbol de trazabilidad</h3>
<button class="close-btn" id="drawer-close"></button>
</div>
<div class="drawer-tabs">
<button class="on" id="tab-orig" onclick="switchTab('orig')">← Origen del dinero</button>
<button id="tab-dest" onclick="switchTab('dest')">Destino →</button>
</div>
<div class="drawer-body" id="drawer-body">
<div class="empty">Selecciona un movimiento.</div>
<div class="panel" id="panel-income">
<div class="panel-head"><h2>Where does the money come from?</h2><p>Real income only — salary, business deposits, and transfers in from other people. Internal moves are excluded.</p></div>
<div id="host-income"></div>
</div>
<div class="panel" id="panel-people">
<div class="panel-head"><h2>Who do you exchange money with?</h2><p>Every named payer and person, with money in versus money out and your net position with each. Counterparties are resolved from transaction descriptions.</p></div>
<div id="host-people"></div>
</div>
<div class="panel" id="panel-cards">
<div class="panel-head"><h2>What is credit costing you?</h2><p>Fees, interest, and taxes charged on your cards and credit lines — plus what you paid back to your own cards.</p></div>
<div id="host-cards"></div>
</div>
<div class="panel" id="panel-cycles">
<div class="panel-head"><h2>Money that just moves in circles</h2><p>The bulk of your statement volume is internal: paying your own cards, sweeping credit lines, and shuffling cash between accounts. None of it is real income or spending.</p></div>
<div id="host-cycles"></div>
</div>
<div class="panel" id="panel-balances">
<div class="panel-head"><h2>How much did you actually have?</h2><p>Running account balances over time, wherever the statements reported them. This turns the flow of money into the level of money you held.</p></div>
<div id="host-balances"></div>
</div>
<div class="panel" id="panel-rhythm">
<div class="panel-head"><h2>When do you spend?</h2><p>The cadence of your spending — by day of the month and by day of the week. Each cell and bar is real purchase activity.</p></div>
<div id="host-rhythm"></div>
</div>
<div class="panel" id="panel-platforms">
<div class="panel-head"><h2>Which payment rails carry your money?</h2><p>The processors and wallets your money flows through — MercadoPago, MACH, Servipag, PayU and more — detected from transaction descriptions and platform tags.</p></div>
<div id="host-platforms"></div>
</div>
<div class="panel" id="panel-banks">
<div class="panel-head"><h2>12 banks, side by side</h2><p>How activity splits across the banks you reconstructed — and which ones hold real money flow versus pure internal cycling.</p></div>
<div id="host-banks"></div>
</div>
<div class="panel" id="panel-ledger">
<div class="panel-head"><h2>The full ledger</h2><p>All 725 reconstructed transactions. Search, filter by type or bank, and sort any column.</p></div>
<div id="host-ledger"></div>
</div>
<div class="panel" id="panel-quality">
<div class="panel-head"><h2>How complete is this picture?</h2><p>What was reconstructed cleanly versus inferred or missing — statement coverage, balance availability, and categorization confidence.</p></div>
<div id="host-quality"></div>
</div>
</div>
</div>
</div>
<div id="tip"></div>
<div id="tweaks-root"></div>
<script src="engine.js"></script>
<script src="charts.js"></script>
<script>
// ── state ─────────────────────────────────────────────────────
let MOVS=[], ENTS=[], ACCS=[], SUM=null;
let byId={}, entById={}, accById={};
let selId=null, activeTab='orig';
// ── helpers ────────────────────────────────────────────────────
const api = () => (document.getElementById('api-base').value||'http://localhost:3910').replace(/\/$/,'');
const fmt = (n, cur='CLP') => n==null ? '' : (cur!=='CLP'?cur+' ':'')+Math.abs(n).toLocaleString('es-CL');
const esc = s => String(s||'').replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
// economic type metadata: [label, color, isIncome, isExpense]
const ET = {
pure_income: ['Ingreso puro', '#4ade9a', true, false],
operating_income: ['Ingreso oper.', '#4ade9a', true, false],
real_expense: ['Gasto real', '#f06a6a', false, true ],
card_charge: ['Cargo tarjeta', '#f06a6a', false, true ],
card_payment: ['Pago tarjeta', '#7070a0', false, false],
wallet_funding: ['Carga wallet', '#7070a0', false, false],
foreign_account_funding: ['Fondeo ext.', '#7070a0', false, false],
internal_transfer: ['Transf. interna', '#7070a0', false, false],
reimbursement: ['Reembolso', '#60d0a0', true, false],
refund: ['Refund', '#60d0a0', true, false],
partner_loan: ['Préstamo socio', '#a78bfa', false, false],
partner_withdrawal: ['Retiro socio', '#a78bfa', false, false],
adjustment: ['Ajuste', '#4a4a70', false, false],
non_accounting: ['No contable', '#2a2a44', false, false],
unknown: ['Desconocido', '#2a2a44', false, false],
};
const etLabel = t => ET[t]?.[0] || t;
const etColor = t => ET[t]?.[1] || '#4a4a70';
const isInc = t => ET[t]?.[2] || false;
const isExp = t => ET[t]?.[3] || false;
function setStatus(ok) {
const dot = document.getElementById('status-dot');
const ndot = document.getElementById('nav-dot');
const txt = document.getElementById('status-txt');
dot.className = ndot.className = 'dot ' + (ok ? 'live' : 'err');
txt.textContent = ok ? 'conectado' : 'error';
}
function showErr(msg) {
const el = document.getElementById('err');
el.textContent = msg; el.style.display = 'block';
}
function clearErr() { document.getElementById('err').style.display = 'none'; }
// ── load ────────────────────────────────────────────────────────
async function load() {
clearErr();
try {
const [sum, er, ar, mr] = await Promise.all([
fetch(api()+'/summary').then(r=>{ if(!r.ok) throw new Error(r.status); return r.json() }),
fetch(api()+'/entities').then(r=>r.json()),
fetch(api()+'/accounts').then(r=>r.json()),
fetch(api()+'/movements').then(r=>r.json()),
]);
setStatus(true);
SUM = sum;
ENTS = er.entities || [];
ACCS = ar.accounts || [];
MOVS = (mr.movements || []).slice().sort((a,b) => (b.date||'').localeCompare(a.date||''));
accById = Object.fromEntries(ACCS.map(a=>[a.id,a]));
entById = Object.fromEntries(ENTS.map(e=>[e.id,e]));
renderAll();
} catch(e) {
setStatus(false);
showErr('No se pudo conectar: '+e.message+' — ¿está corriendo npm run serve?');
}
}
function renderAll() {
renderKPIs();
renderChart();
renderEntities();
renderMovements();
}
// ── KPIs ────────────────────────────────────────────────────────
function renderKPIs() {
const c = SUM.counts;
const bt = SUM.movementAmountByType || {};
let inc=0, incN=0, exp=0, expN=0;
for (const m of MOVS) {
if (isInc(m.economicType)) { inc += m.amount?.value||0; incN++ }
if (isExp(m.economicType)) { exp += m.amount?.value||0; expN++ }
}
const net = inc - exp;
document.getElementById('k-income').textContent = fmt(inc);
document.getElementById('k-income-c').textContent = incN + ' movimientos';
document.getElementById('k-expense').textContent = fmt(exp);
document.getElementById('k-expense-c').textContent = expN + ' movimientos';
const nel = document.getElementById('k-net');
nel.textContent = (net >= 0 ? '+' : '-') + fmt(Math.abs(net));
nel.style.color = net >= 0 ? 'var(--in)' : 'var(--out)';
document.getElementById('k-mov').textContent = c.movements;
document.getElementById('k-mov-sub').textContent = c.accounts + ' cuentas · ' + c.entities + ' entidades';
document.getElementById('k-links').textContent = c.links;
document.getElementById('k-links-sub').textContent = c.documents + ' documentos · ' + c.events + ' eventos';
document.getElementById('sub-label').textContent = '· ' + c.entities + ' entidades · ' + c.movements + ' movimientos';
}
// ── donut chart ─────────────────────────────────────────────────
function renderChart() {
const bt = SUM.movementAmountByType || {};
const entries = Object.entries(bt).filter(([,v])=>v>0).sort((a,b)=>b[1]-a[1]);
const total = entries.reduce((s,[,v])=>s+v, 0);
if (!total) { document.getElementById('chart-wrap').innerHTML='<div class="empty">Sin datos.</div>'; return; }
// populate filter
const sel = document.getElementById('etype-sel');
while (sel.options.length > 1) sel.remove(1);
entries.forEach(([t]) => sel.add(new Option(etLabel(t), t)));
// SVG donut
const R=52, r=34, cx=60, cy=60, TAU=2*Math.PI;
let angle = -Math.PI/2;
const segments = entries.map(([t,v]) => {
const pct = v/total, sweep = pct*TAU;
const x1=cx+R*Math.cos(angle), y1=cy+R*Math.sin(angle);
angle += sweep;
const x2=cx+R*Math.cos(angle), y2=cy+R*Math.sin(angle);
const lf = sweep > Math.PI ? 1 : 0;
const path = `M${cx},${cy} L${x1},${y1} A${R},${R},0,${lf},1,${x2},${y2} Z`;
return { t, v, pct, path, color: etColor(t) };
/* point the renderers at the host divs inside each panel */
window.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.panel').forEach(p => {
const tab = p.id.replace('panel-', '');
const host = document.getElementById('host-' + tab);
if (host) p._host = host;
});
// inner hole mask
const svgH = `<svg width="120" height="120" viewBox="0 0 120 120">
${segments.map(s=>`<path d="${esc(s.path)}" fill="${s.color}" opacity=".85"/>`).join('')}
<circle cx="60" cy="60" r="${r}" fill="var(--surface)"/>
</svg>`;
const legend = segments.slice(0,6).map(s => {
const pct = Math.round(s.pct*100);
return `<div class="legend-row">
<div class="legend-dot" style="background:${s.color}"></div>
<div class="legend-label">${esc(etLabel(s.t))}</div>
<div class="legend-amt">${fmt(s.v)}</div>
<div class="legend-pct">${pct}%</div>
</div>`;
}).join('');
document.getElementById('chart-wrap').innerHTML = `
<div class="donut-wrap">
${svgH}
<div class="donut-center">
<div class="dc-val">${entries.length}</div>
<div class="dc-sub">tipos</div>
</div>
</div>
<div class="donut-legend">${legend}</div>`;
}
// ── entities ────────────────────────────────────────────────────
function renderEntities() {
const acctsByEnt = {};
for (const a of ACCS) {
if (!acctsByEnt[a.ownerEntityId]) acctsByEnt[a.ownerEntityId] = [];
acctsByEnt[a.ownerEntityId].push(a);
}
const hints = SUM.movementAmountByEntityHint || {};
document.getElementById('ent-count').textContent = ENTS.length + ' entidades';
const html = ENTS.map(e => {
const init = (e.name||e.id).split(/\s+/).slice(0,2).map(w=>w[0]).join('').toUpperCase();
const cls = e.kind==='company' ? 'company' : e.kind==='vendor' ? 'vendor' : '';
const accts = acctsByEnt[e.id] || [];
const net = hints[e.id];
const netHtml = net!=null
? `<div class="entity-net ${net>=0?'':'out'}" style="color:${net>=0?'var(--in)':'var(--out)'}">${net>=0?'+':''}${fmt(net)}</div>` : '';
const acctHtml = accts.map(a=>`<div class="acct-chip">${esc(a.label||a.id)}</div>`).join('');
return `<div class="entity-row">
<div class="avatar ${cls}">${esc(init)}</div>
<div class="entity-info">
<div class="entity-name">${esc(e.name||e.id)}</div>
<div class="entity-meta">${esc(e.kind||'')}${e.rut?' · '+e.rut:''}</div>
${accts.length?'<div class="entity-accts">'+acctHtml+'</div>':''}
</div>
${netHtml}
</div>`;
}).join('');
document.getElementById('entity-list').innerHTML = html || '<div class="empty">Sin entidades.</div>';
}
// ── movements ────────────────────────────────────────────────────
function currentMovs() {
const q = document.getElementById('mov-q').value.toLowerCase().trim();
const dir = document.querySelector('#dir-seg .on').dataset.d;
const et = document.getElementById('etype-sel').value;
return MOVS.filter(m => {
if (dir!=='all' && m.direction!==dir) return false;
if (et && m.economicType!==et) return false;
if (q) {
const hay = ((m.description||'')+' '+(m.counterparty||'')+' '+m.id).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
function renderMovements() {
const movs = currentMovs();
const feed = document.getElementById('mov-feed');
document.getElementById('mov-empty').hidden = movs.length > 0;
feed.innerHTML = movs.map(m => {
const cls = m.direction==='in'?'in':'out';
const icon = m.direction==='in' ? '↑' : '↓';
const color = etColor(m.economicType);
const sel = m.id===selId ? ' selected' : '';
const acct = accById[m.accountId];
return `<div class="mov-item${sel}" data-id="${esc(m.id)}">
<div class="mov-icon ${cls}">${icon}</div>
<div class="mov-body">
<div class="mov-top">
<div class="mov-desc">${esc(m.description||m.id)}</div>
<div class="mov-amt ${cls}">${cls==='in'?'+':'-'}${fmt(m.amount?.value, m.amount?.currency)}</div>
</div>
<div class="mov-bot">
<span class="mov-date">${m.date||''}</span>
${m.counterparty?`<span class="mov-cpty">→ ${esc(m.counterparty)}</span>`:''}
<span class="etype-chip" style="background:${color}22;color:${color}">${esc(etLabel(m.economicType))}</span>
${acct?`<span class="mov-date">${esc(acct.label||acct.id)}</span>`:''}
</div>
</div>
<button class="tree-trigger" data-id="${esc(m.id)}">árbol ↗</button>
</div>`;
}).join('');
feed.querySelectorAll('.mov-item').forEach(el => el.addEventListener('click', e => {
if (e.target.classList.contains('tree-trigger')) return;
openDrawer(el.dataset.id);
}));
feed.querySelectorAll('.tree-trigger').forEach(b => b.addEventListener('click', e => {
e.stopPropagation(); openDrawer(b.dataset.id);
}));
}
document.getElementById('mov-q').addEventListener('input', renderMovements);
document.getElementById('etype-sel').addEventListener('change', renderMovements);
document.querySelectorAll('#dir-seg button').forEach(b => b.addEventListener('click', () => {
document.querySelectorAll('#dir-seg button').forEach(x=>x.classList.remove('on'));
b.classList.add('on'); renderMovements();
}));
// ── drawer ───────────────────────────────────────────────────────
async function openDrawer(movId) {
selId = movId;
document.querySelectorAll('.mov-item').forEach(el => el.classList.toggle('selected', el.dataset.id===movId));
const m = MOVS.find(x=>x.id===movId);
document.getElementById('drawer-title').textContent = m?.description || movId;
document.getElementById('drawer').classList.add('open');
await loadTree(movId, activeTab);
}
async function loadTree(movId, tab) {
const body = document.getElementById('drawer-body');
body.innerHTML = '<div class="empty"><span class="spin"></span>cargando árbol…</div>';
try {
const path = tab==='orig'
? `/nodes/${encodeURIComponent(movId)}/origin-tree`
: `/nodes/${encodeURIComponent(movId)}/destination-tree`;
const tree = await fetch(api()+path).then(r=>r.json());
body.innerHTML = renderTree(tree, tab==='orig');
} catch(e) {
body.innerHTML = `<div class="empty">Error: ${esc(e.message)}</div>`;
}
}
function switchTab(tab) {
activeTab = tab;
document.getElementById('tab-orig').classList.toggle('on', tab==='orig');
document.getElementById('tab-dest').classList.toggle('on', tab==='dest');
if (selId) loadTree(selId, tab);
}
document.getElementById('drawer-close').addEventListener('click', () => {
document.getElementById('drawer').classList.remove('open');
selId = null;
document.querySelectorAll('.mov-item.selected').forEach(el=>el.classList.remove('selected'));
});
</script>
<script src="dashboard.js"></script>
<script src="dashboard2.js"></script>
// ── tree renderer ────────────────────────────────────────────────
function renderTree(branch, isOrig) {
const lines = [];
function visit(b) {
const n = b.node || {};
const label = n.description || n.subject || n.label || n.name || n.issuerName || n.id || '?';
const amtH = n.amount ? ` <span class="tn-amt">${fmt(n.amount.value,n.amount.currency)}</span>` : '';
const dtH = (n.date||n.documentDate) ? ` <span class="tn-date">${n.date||n.documentDate}</span>` : '';
const kindH = n.kind ? `<span class="tn-kind">${esc(n.kind)}</span>` : '';
if (b.cycle) { lines.push(`<div class="t-cycle">↩ ciclo: ${esc(label)}</div>`); return }
if (b.truncated) { lines.push(`<div class="t-cycle">… truncado</div>`); return }
lines.push(`<div class="tn"><div class="tn-label">${kindH}<span class="tn-id">${esc(n.id||'')}</span>${dtH} <span class="tn-desc">${esc(label)}</span>${amtH}</div>`);
const edges = isOrig ? (b.incoming||[]) : (b.outgoing||[]);
if (edges.length) {
lines.push('<div class="tc">');
for (const e of edges) {
const lk = e.link || {};
const la = lk.amount ? ' '+fmt(lk.amount.value,lk.amount.currency) : '';
const lm = lk.method ? ` <span class="tl-meth">(${esc(lk.method)})</span>` : '';
lines.push(`<div class="tl-label"><span class="tl-type">${esc(lk.type||'')}</span>${la}${lm}</div>`);
visit(isOrig ? e.source : e.target);
}
lines.push('</div>');
}
lines.push('</div>');
}
visit(branch);
return lines.join('');
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel">
const TWEAK_DEFAULTS = { palette: ['#2ee6a6', '#ff5f73', '#b98cff', '#ffc24b'], bg: '#090b0f' };
function applyTweaks(t) {
const r = document.documentElement.style;
r.setProperty('--c-income', t.palette[0]); r.setProperty('--c-spend', t.palette[1]);
r.setProperty('--c-person', t.palette[2]); r.setProperty('--c-fee', t.palette[3]);
r.setProperty('--bg', t.bg);
if (window.MTapply) window.MTapply();
}
// ── boot ─────────────────────────────────────────────────────────
document.getElementById('reload-btn').addEventListener('click', load);
document.getElementById('api-base').addEventListener('keydown', e => { if(e.key==='Enter') load() });
load();
function TweakApp() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
React.useEffect(() => { applyTweaks(t); }, [t]);
return (
<TweaksPanel title="Tweaks">
<TweakSection label="Palette" hint="income · spending · people · fees" />
<TweakColor label="Colors" value={t.palette} onChange={v => setTweak('palette', v)}
options={[
['#2ee6a6', '#ff5f73', '#b98cff', '#ffc24b'],
['#34d399', '#fb7185', '#a78bfa', '#fbbf24'],
['#00e5a0', '#ff4d6d', '#8c9eff', '#ffd45a'],
['#7CFFB2', '#FF8FA3', '#C9A6FF', '#FFE08A']
]} />
<TweakSection label="Background" />
<TweakColor label="Canvas" value={t.bg} onChange={v => setTweak('bg', v)}
options={['#090b0f', '#0c0a0e', '#0a0d0c', '#0d0d0d']} />
</TweaksPanel>
);
}
ReactDOM.createRoot(document.getElementById('tweaks-root')).render(<TweakApp />);
</script>
</body>
</html>

327
web/dashboard.js Normal file
View File

@ -0,0 +1,327 @@
/* money-trace · dashboard controller + 5 perspective tabs */
(function () {
'use strict';
const $ = s => document.querySelector(s);
const C = window.Charts;
const col = {
income: () => cssv('--c-income'), spend: () => cssv('--c-spend'),
person: () => cssv('--c-person'), fee: () => cssv('--c-fee'),
internal: () => cssv('--c-internal'), ink: () => cssv('--ink'), mute: () => cssv('--ink-mute')
};
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const esc = C.esc;
// ---------- tooltip ----------
const tip = $('#tip');
function place(e) {
const w = tip.offsetWidth, h = tip.offsetHeight, pad = 16;
let x = e.clientX + pad, y = e.clientY + pad;
if (x + w > innerWidth - 8) x = e.clientX - w - pad;
if (y + h > innerHeight - 8) y = e.clientY - h - pad;
tip.style.left = x + 'px'; tip.style.top = y + 'px';
}
window.MTtip = {
raw(e, title, sub, c) {
tip.innerHTML = `<div class="t-head"><span class="t-dot" style="background:${c || col.mute()}"></span><span class="t-title">${esc(title)}</span></div><div class="t-meta" style="margin:0">${esc(sub)}</div>`;
tip.classList.add('show'); place(e);
},
rows(e, title, c, txns) {
const top = txns.slice().sort((a, b) => b.amount - a.amount).slice(0, 6);
let html = `<div class="t-head"><span class="t-dot" style="background:${c}"></span><span class="t-title">${esc(title)}</span></div>
<div class="t-amt num" style="color:${c}">${window.MT.CLP(txns.reduce((a, t) => a + t.amount, 0))}</div>
<div class="t-meta">${txns.length} transactions</div><div class="t-rows">`;
for (const t of top) html += `<div class="t-row"><span class="d">${t.date.slice(5).replace('-', '/')}</span><span class="desc">${esc(window.MT.cleanDesc(t.description) || t.counterparty || '—')}</span><span class="a">${window.MT.CLP(t.amount)}</span></div>`;
html += '</div>';
if (txns.length > 6) html += `<div class="t-more">+${txns.length - 6} more</div>`;
tip.innerHTML = html; tip.classList.add('show'); place(e);
},
hide() { tip.classList.remove('show'); }
};
// ---------- aggregation ----------
const TX = () => window.MT.tx;
const sum = arr => arr.reduce((a, t) => a + t.amount, 0);
const groupSum = (arr, keyFn) => {
const m = {};
for (const t of arr) { const k = keyFn(t) || '—'; (m[k] = m[k] || { value: 0, txns: [] }); m[k].value += t.amount; m[k].txns.push(t); }
return m;
};
const sortEntries = m => Object.entries(m).sort((a, b) => b[1].value - a[1].value);
const isExpense = t => t.flow_type === 'expense';
const isFee = t => t.flow_type === 'fee';
const isIncome = t => t.flow_type === 'income';
const isInterIn = t => t.flow_type === 'inter_person' && t.direction === 'credit';
const isInterOut = t => t.flow_type === 'inter_person' && t.direction === 'debit';
const isInternal = t => ['self_transfer', 'credit_line', 'card_payment'].includes(t.flow_type);
// ---------- shared totals ----------
let TOT;
function computeTotals() {
const tx = TX();
const realIn = sum(tx.filter(t => isIncome(t) || isInterIn(t)));
const realOut = sum(tx.filter(t => isExpense(t) || isFee(t) || isInterOut(t)));
const internal = sum(tx.filter(isInternal));
TOT = { realIn, realOut, internal, net: realIn - realOut, gross: realIn + realOut + internal };
}
const fmtBig = v => {
const sign = v < 0 ? '' : ''; const a = Math.abs(v);
if (a >= 1e6) return sign + '$' + (a / 1e6).toLocaleString('es-CL', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + '<small>M</small>';
if (a >= 1e3) return sign + '$' + Math.round(a / 1e3).toLocaleString('es-CL') + '<small>k</small>';
return sign + window.MT.CLP(a);
};
const kpi = (lab, val, sub, accent) =>
`<div class="kpi" style="--accent:${accent}"><div class="k-lab">${lab}</div><div class="k-val">${val}</div>${sub ? `<div class="k-sub">${sub}</div>` : ''}</div>`;
// ============================================================ TAB 1: OVERVIEW
function renderOverview(host) {
const tx = TX();
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 mult = TOT.gross / (TOT.realIn + TOT.realOut);
host.innerHTML = `
<div class="grid g-3" style="margin-bottom:16px;">
${kpi('Real income', fmtBig(TOT.realIn), '93 deposits & transfers in', col.income())}
${kpi('Real spending', fmtBig(TOT.realOut), 'purchases · fees · people', col.spend())}
${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="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>
<div class="donut-wrap"><div id="ov-donut"></div>
<div class="chips" style="flex-direction:column;gap:10px;margin:0;">
<span class="chip"><i style="background:${col.income()}"></i>Real money <b>${window.MT.CLPk(TOT.realIn + TOT.realOut)}</b></span>
<span class="chip"><i style="background:${col.internal()}"></i>Internal shuffle <b>${window.MT.CLPk(TOT.internal)}</b></span>
</div>
</div>
<div class="insight" style="margin-top:18px;"><div class="i-ic"></div><div>For every <b>$1</b> of real money, <span class="num">$${mult.toFixed(1)}</span> moved between your own accounts. Most of the statement volume is noise.</div></div>
</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, [
{ key: 'in', label: 'Money in', color: col.income(), values: inByM },
{ key: 'out', label: 'Money out', color: col.spend(), values: outByM }
], { height: 230 });
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() });
}
// ============================================================ TAB 2: SPENDING
function renderSpending(host) {
const tx = TX();
const spend = tx.filter(t => isExpense(t) || isFee(t));
const total = sum(spend);
const byCat = groupSum(spend, t => isFee(t) ? 'Fees & interest' : window.MT.spendCategory(t.description));
const cats = sortEntries(byCat);
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)));
// top merchants
const byMerch = groupSum(tx.filter(isExpense), t => window.MT.cleanDesc(t.description) || 'Unknown');
const merch = sortEntries(byMerch).slice(0, 10);
const biggestCat = cats[0];
host.innerHTML = `
<div class="grid g-3" style="margin-bottom:16px;">
${kpi('Total spending', fmtBig(total), `${spend.length} purchases & fees`, col.spend())}
${kpi('Biggest category', biggestCat[0], `${window.MT.CLPk(biggestCat[1].value)} · ${Math.round(biggestCat[1].value / total * 100)}% of spend`, palette[0])}
${kpi('Avg / month', fmtBig(total / months.length), 'across ' + months.length + ' months', col.fee())}
</div>
<div class="grid g-2-1">
<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>
`;
C.hbars($('#sp-cats'), cats.map(([label, d], i) => ({
label, value: d.value, color: catColor(i), sub: d.txns.length + ' txns · ' + Math.round(d.value / total * 100) + '%',
_txns: d.txns, _c: catColor(i)
})), { max: cats[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
const ml = $('#sp-merch');
merch.forEach(([name, d], i) => {
const cat = window.MT.spendCategory(d.txns[0].description);
const row = document.createElement('div'); row.className = 'mrow';
row.innerHTML = `<span class="m-rank">${i + 1}</span><span class="m-name">${esc(name)}<span class="m-cat">${esc(cat)}</span></span><span class="m-cnt">${d.txns.length}×</span><span class="m-amt">${window.MT.CLPk(d.value)}</span>`;
row.addEventListener('mousemove', e => window.MTtip.rows(e, name, col.spend(), d.txns));
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 });
}
// ============================================================ TAB 3: INCOME
function renderIncome(host) {
const tx = TX();
const inc = tx.filter(t => isIncome(t) || isInterIn(t));
const total = sum(inc);
const bySrc = groupSum(inc, t => isIncome(t) ? window.MT.normIncome(t.counterparty) : window.MT.normPerson(t.counterparty));
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 top3 = srcs.slice(0, 3).reduce((a, [, d]) => a + d.value, 0);
const conc = Math.round(top3 / total * 100);
host.innerHTML = `
<div class="grid g-3" style="margin-bottom:16px;">
${kpi('Total income', fmtBig(total), `${inc.length} deposits & transfers in`, col.income())}
${kpi('Top 3 sources', conc + '%', 'of all income comes from 3 payers', col.income())}
${kpi('Avg / month', fmtBig(total / months.length), 'across ' + months.length + ' months', col.person())}
</div>
<div class="grid g-2-1">
<div class="card"><h3>Where it comes from<span class="pill">by source</span></h3><div id="in-src"></div></div>
<div class="card"><h3>Concentration</h3>
<div class="donut-wrap"><div id="in-donut"></div></div>
<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>
`;
C.hbars($('#in-src'), srcs.map(([label, d], i) => ({
label, value: d.value, color: i < palette.length ? palette[i] : col.mute(),
sub: d.txns.length + ' deposits · ' + Math.round(d.value / total * 100) + '%',
_txns: d.txns, _c: i < palette.length ? palette[i] : col.mute()
})), { max: srcs[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
// donut top 5 + other
const top5 = srcs.slice(0, 5);
const otherV = total - top5.reduce((a, [, d]) => a + d.value, 0);
const donutData = top5.map(([label, d], i) => ({ label, value: d.value, color: palette[i] }));
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 });
}
// ============================================================ TAB 4: CYCLES
function renderCycles(host) {
const tx = TX();
const internal = tx.filter(isInternal);
const total = sum(internal);
const byType = {
'Card payments': { value: sum(tx.filter(t => t.flow_type === 'card_payment')), c: tx.filter(t => t.flow_type === 'card_payment'), color: '#6f7b90', label: 'Account → its own credit card' },
'Credit-line sweeps': { value: sum(tx.filter(t => t.flow_type === 'credit_line')), c: tx.filter(t => t.flow_type === 'credit_line'), color: '#566173', label: 'Cuenta corriente ↔ línea de crédito' },
'Self-transfers': { value: sum(tx.filter(t => t.flow_type === 'self_transfer')), c: tx.filter(t => t.flow_type === 'self_transfer'), color: '#7d899e', label: 'Between your own accounts' }
};
const mult = TOT.gross / (TOT.realIn + TOT.realOut);
const byBank = groupSum(internal, t => t.bank);
const banks = sortEntries(byBank).slice(0, 8);
host.innerHTML = `
<div class="card" style="margin-bottom:16px;">
<div class="callout"><div class="big mult">×${mult.toFixed(1)}</div><div class="ctxt">Your statements show <b>${window.MT.CLPk(TOT.gross)}</b> of gross movement, but only <b>${window.MT.CLPk(TOT.realIn + TOT.realOut)}</b> is real money in or out.<br/><b>${window.MT.CLPk(total)}</b> is internal shuffling money that never left your perimeter.</div></div>
</div>
<div class="grid g-2">
<div class="card"><h3>What the cycling is<span class="pill">by type</span></h3><div id="cy-types"></div>
<div class="insight" style="margin-top:16px;"><div class="i-ic">i</div><div>These flows net to roughly zero they're you paying your own cards, sweeping credit lines, and moving cash between accounts.</div></div>
</div>
<div class="card"><h3>Which banks cycle most<span class="pill">internal volume</span></h3><div id="cy-banks"></div></div>
</div>
`;
const typeArr = Object.entries(byType);
C.hbars($('#cy-types'), typeArr.map(([label, d]) => ({
label, value: d.value, color: d.color, sub: d.label + ' · ' + d.c.length + ' txns', _txns: d.c, _c: d.color
})), { max: Math.max(...typeArr.map(([, d]) => d.value)), onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
C.hbars($('#cy-banks'), banks.map(([label, d]) => ({
label, value: d.value, color: col.internal(), sub: d.txns.length + ' txns', _txns: d.txns, _c: col.internal()
})), { max: banks[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
}
// ============================================================ TAB 5: BANKS
function renderBanks(host) {
const tx = TX();
const raw = window.MT.raw;
const banks = window.MT.banks.map(bk => {
const bt = tx.filter(t => t.bank === bk);
const realIn = sum(bt.filter(t => isIncome(t) || isInterIn(t)));
const realOut = sum(bt.filter(t => isExpense(t) || isFee(t) || isInterOut(t)));
const internal = sum(bt.filter(isInternal));
const vol = realIn + realOut + internal;
// docs from raw
let docs = 0; for (const s of raw.statements) if (window.MT.bankName(s.bank) === bk) docs++;
return { bk, txns: bt.length, docs, realIn, realOut, internal, vol, real: realIn + realOut };
}).sort((a, b) => b.vol - a.vol);
const maxVol = Math.max(...banks.map(b => b.vol));
const totalReal = banks.reduce((a, b) => a + b.real, 0);
const totalInt = banks.reduce((a, b) => a + b.internal, 0);
host.innerHTML = `
<div class="grid g-3" style="margin-bottom:16px;">
${kpi('Banks reconstructed', '12', `${window.MT.tx.length} txns · ${raw.statements.length} statements`, col.income())}
${kpi('Most active', banks[0].bk, `${window.MT.CLPk(banks[0].vol)} total volume`, col.income())}
${kpi('Real vs internal', Math.round(totalReal / (totalReal + totalInt) * 100) + '%', 'of all volume is real money', col.fee())}
</div>
<div class="card"><h3>Per-bank breakdown<span class="pill">sorted by volume</span></h3>
<table class="btable"><thead><tr>
<th>Bank</th><th>Txns</th><th>Real in</th><th>Real out</th><th>Internal</th><th>Volume</th><th style="width:150px;">Real vs internal</th>
</tr></thead><tbody id="bk-rows"></tbody></table>
</div>
`;
const tb = $('#bk-rows');
banks.forEach(b => {
const realPct = b.vol ? b.real / b.vol * 100 : 0;
const tr = document.createElement('tr');
tr.innerHTML = `
<td><div class="bk-name"><span class="bk-dot" style="background:${b.real >= b.internal ? col.income() : col.internal()}"></span><div>${esc(b.bk)}<div class="bk-meta">${b.docs} statements</div></div></div></td>
<td>${b.txns}</td>
<td style="color:${col.income()}">${b.realIn ? window.MT.CLPk(b.realIn) : '<span class="muted">—</span>'}</td>
<td style="color:${col.spend()}">${b.realOut ? window.MT.CLPk(b.realOut) : '<span class="muted">—</span>'}</td>
<td class="muted">${b.internal ? window.MT.CLPk(b.internal) : '—'}</td>
<td>${window.MT.CLPk(b.vol)}</td>
<td><span class="minibar" title="${Math.round(realPct)}% real"><i style="width:${realPct}%;background:${col.income()}"></i><i style="width:${100 - realPct}%;background:${col.internal()}"></i></span></td>`;
tb.appendChild(tr);
});
}
// ---------- tab wiring ----------
const RENDERERS = { overview: renderOverview, spending: renderSpending, income: renderIncome, cycles: renderCycles, banks: renderBanks };
const rendered = {};
// shared API for the extra-tabs module (dashboard2.js)
window.MTdash = {
TX, sum, groupSum, sortEntries, col, cssv, esc, fmtBig, kpi,
isExpense, isFee, isIncome, isInterIn, isInterOut, isInternal,
get TOT() { return TOT; },
register(name, fn) { RENDERERS[name] = fn; }
};
function show(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('on', t.dataset.tab === tab));
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('on', p.id === 'panel-' + tab));
const host = $('#host-' + tab);
// always re-render to pick up resize/tweak changes
RENDERERS[tab](host);
rendered[tab] = true;
localStorage.setItem('mt-dash-tab', tab);
}
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => show(t.dataset.tab)));
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)
window.MTapply = () => { if (!TOT) return; const cur = document.querySelector('.tab.on'); if (cur) RENDERERS[cur.dataset.tab]($('#host-' + cur.dataset.tab)); };
// ---------- boot ----------
window.MT.load().then(() => {
computeTotals();
$('.brand .sub').textContent = `${window.MT.tx.length} txns · ${window.MT.banks.length} banks · ${window.MT.months.length} months`;
const start = localStorage.getItem('mt-dash-tab') || 'overview';
show(RENDERERS[start] ? start : 'overview');
$('#loading').style.display = 'none';
$('#app').style.display = 'flex';
}).catch(err => { $('#loading').textContent = 'failed to load ledger.json — ' + err.message; });
})();

435
web/dashboard2.js Normal file
View File

@ -0,0 +1,435 @@
/* money-trace · dashboard part 2 extended perspective tabs
Balances · People · Cards & credit · Rhythm · Ledger explorer · Data quality
Registers into the controller via window.MTdash.register(). */
(function () {
'use strict';
const D = window.MTdash, C = window.Charts, MT = window.MT;
const { TX, sum, groupSum, sortEntries, col, cssv, esc, fmtBig, kpi } = D;
const { isExpense, isFee, isIncome, isInterIn, isInterOut, isInternal } = D;
const $ = s => document.querySelector(s);
const CLPk = MT.CLPk, CLP = MT.CLP;
const ms = ymd => +new Date(ymd + 'T00:00:00');
// palette for many-series
const SERIES_COLORS = ['#2ee6a6', '#6fd0ff', '#b98cff', '#ffc24b', '#ff8f6b', '#5fd0a0', '#8c9eff', '#ff5f73', '#d0d6e0', '#46c6ba'];
// ============================================================ BALANCES & RUNWAY
function renderBalances(host) {
const tx = TX();
// group transactions that carry a balance, by account
const acctMap = {};
for (const t of tx) {
if (t.balance == null || t.doc_type === 'tarjeta_credito') continue;
const key = t.bank + '·' + (t.last4 || t.doc_type) + '·' + t.doc_type;
(acctMap[key] = acctMap[key] || { bank: t.bank, last4: t.last4, doc_type: t.doc_type, pts: [] });
acctMap[key].pts.push({ t: ms(t.date), v: t.balance, tx: t });
}
// keep accounts with >=4 points, sort by point count
let accts = Object.entries(acctMap).map(([k, a]) => ({ key: k, ...a }))
.filter(a => a.pts.length >= 4)
.sort((a, b) => b.pts.length - a.pts.length);
accts.forEach((a, i) => { a.color = SERIES_COLORS[i % SERIES_COLORS.length]; a.pts.sort((p, q) => p.t - q.t); a.last = a.pts[a.pts.length - 1].v; a.min = Math.min(...a.pts.map(p => p.v)); });
// default selection: top 4
if (!host._sel) host._sel = new Set(accts.slice(0, 4).map(a => a.key));
const sel = host._sel;
const lowest = accts.slice().sort((a, b) => a.min - b.min)[0];
const liquid = accts.filter(a => a.doc_type !== 'linea_credito').reduce((s, a) => s + a.last, 0);
host.innerHTML = `
<div class="grid g-3" style="margin-bottom:16px;">
${kpi('Accounts with balances', accts.length, 'of 17 reconstructed accounts', col.income())}
${kpi('Latest liquid total', fmtBig(liquid), 'sum of last-known cash balances', col.income())}
${kpi('Lowest point', fmtBig(lowest ? lowest.min : 0), lowest ? `${esc(lowest.bank)} ··${lowest.last4 || ''}` : '', lowest && lowest.min < 0 ? col.spend() : col.fee())}
</div>
<div class="card">
<h3>Balance over time<span class="pill">running balance, where statements report it</span></h3>
<div class="acct-chips" id="bal-chips"></div>
<div class="chart-host" id="bal-chart"></div>
</div>
<div class="insight" style="margin-top:16px;"><div class="i-ic">i</div><div>Credit cards don't report a running balance in these statements, so they're excluded here. Coverage varies by account lines connect only the points each statement actually logged.</div></div>
`;
const chips = $('#bal-chips');
accts.forEach(a => {
const c = document.createElement('div'); c.className = 'acct-chip' + (sel.has(a.key) ? ' on' : '');
c.innerHTML = `<i style="background:${a.color}"></i>${esc(a.bank)}<span class="ac-end">${MT.acctShort(a.doc_type)}${a.last4 ? ' ··' + a.last4 : ''}</span>`;
c.addEventListener('click', () => { sel.has(a.key) ? sel.delete(a.key) : sel.add(a.key); renderBalances(host); });
chips.appendChild(c);
});
const series = accts.filter(a => sel.has(a.key)).map(a => ({
label: a.bank + ' ' + MT.acctShort(a.doc_type) + (a.last4 ? ' ··' + a.last4 : ''),
color: a.color, points: a.pts
}));
C.multiLine($('#bal-chart'), series, { height: 320 });
}
// ============================================================ PEOPLE
function renderPeople(host) {
const tx = TX();
// build per-person ledger from income (in), inter_person (in/out)
const ppl = {};
function add(name, t, dir) {
const k = name;
(ppl[k] = ppl[k] || { name, in: 0, out: 0, inTx: [], outTx: [], dates: [] });
if (dir === 'in') { ppl[k].in += t.amount; ppl[k].inTx.push(t); }
else { ppl[k].out += t.amount; ppl[k].outTx.push(t); }
ppl[k].dates.push(t.date);
}
for (const t of tx) {
if (isIncome(t)) add(MT.normIncome(t.counterparty), t, 'in');
else if (isInterIn(t)) add(MT.normPerson(t.counterparty), t, 'in');
else if (isInterOut(t)) add(MT.normPerson(t.counterparty), t, 'out');
}
let people = Object.values(ppl).map(p => {
p.net = p.in - p.out; p.gross = p.in + p.out; p.n = p.inTx.length + p.outTx.length;
p.dates.sort(); p.first = p.dates[0]; p.last = p.dates[p.dates.length - 1];
return p;
}).filter(p => p.name && p.name !== '—').sort((a, b) => b.gross - a.gross);
const payers = people.filter(p => p.net > 0).slice(0, 1);
const namedIncome = people.reduce((s, p) => s + p.in, 0);
host.innerHTML = `
<div class="grid g-3" style="margin-bottom:16px;">
${kpi('People & payers', people.length, 'distinct counterparties with a name', col.person())}
${kpi('Biggest net source', payers.length ? esc(payers[0].name) : '—', payers.length ? '+' + CLPk(payers[0].net) + ' net to you' : '', col.income())}
${kpi('Named-source income', fmtBig(namedIncome), 'total in from identified people', col.income())}
</div>
<div class="card"><h3>Your money relationships<span class="pill">money in · money out · net</span></h3>
<table class="btable"><thead><tr>
<th>Person / payer</th><th>Txns</th><th>Money in</th><th>Money out</th><th>Net</th><th style="width:170px;">In vs out</th><th>Last seen</th>
</tr></thead><tbody id="ppl-rows"></tbody></table>
</div>
`;
const tb = $('#ppl-rows');
const maxGross = Math.max(...people.map(p => p.gross), 1);
people.forEach(p => {
const inPct = p.gross ? p.in / p.gross * 100 : 0;
const tr = document.createElement('tr');
tr.innerHTML = `
<td><div class="bk-name"><span class="bk-dot" style="background:${p.net >= 0 ? col.income() : col.spend()}"></span>${esc(p.name)}</div></td>
<td>${p.n}</td>
<td style="color:${col.income()}">${p.in ? CLPk(p.in) : '<span class="muted">—</span>'}</td>
<td style="color:${col.spend()}">${p.out ? CLPk(p.out) : '<span class="muted">—</span>'}</td>
<td class="${p.net >= 0 ? 'net-pos' : 'net-neg'}">${p.net >= 0 ? '+' : ''}${CLPk(Math.abs(p.net))}</td>
<td><span class="minibar" style="width:${10 + p.gross / maxGross * 140}px"><i style="width:${inPct}%;background:${col.income()}"></i><i style="width:${100 - inPct}%;background:${col.spend()}"></i></span></td>
<td class="muted" style="font-size:11px;">${p.last ? p.last.slice(0, 7) : ''}</td>`;
tr.addEventListener('mousemove', e => window.MTtip.rows(e, p.name, p.net >= 0 ? col.income() : col.spend(), [...p.inTx, ...p.outTx]));
tr.addEventListener('mouseleave', () => window.MTtip.hide());
tb.appendChild(tr);
});
}
// ============================================================ CARDS & CREDIT
function renderCards(host) {
const tx = TX();
const fees = tx.filter(isFee);
const feeTotal = sum(fees);
// classify fee descriptions
const feeCat = d => {
const u = (d || '').toUpperCase();
if (/INTERES|MORA|ROTATIV/.test(u)) return 'Interest';
if (/IMPUESTO|DL 3475|DECRETO/.test(u)) return 'Taxes & stamp';
if (/COBRANZA/.test(u)) return 'Collection charges';
if (/COMIS|MANTENCION|MANTENC|ADMIN|SERVICIO|CARGO/.test(u)) return 'Commissions & maintenance';
return 'Other charges';
};
const byFee = groupSum(fees, t => feeCat(t.description));
const feeArr = sortEntries(byFee);
const FEE_C = { 'Interest': '#ff5f73', 'Taxes & stamp': '#ffc24b', 'Collection charges': '#ff8f6b', 'Commissions & maintenance': '#b98cff', 'Other charges': '#7a8699' };
// credit-line activity: draws (debit on cuenta = borrowing) vs repayments
const lineTx = tx.filter(t => t.flow_type === 'credit_line');
const cardPay = tx.filter(t => t.flow_type === 'card_payment');
const cardPayTotal = sum(cardPay);
const months = MT.months;
const feeByM = {}; months.forEach(m => feeByM[m] = sum(fees.filter(t => t.ym === m)));
// which cards/lines exist
const cardAccts = {};
for (const t of tx) {
if (!MT.isCardType(t.doc_type)) continue;
const k = t.bank + ' · ' + MT.acctShort(t.doc_type);
(cardAccts[k] = cardAccts[k] || { bank: t.bank, type: t.doc_type, fees: 0, spend: 0, n: 0 });
cardAccts[k].n++;
if (isFee(t)) cardAccts[k].fees += t.amount;
if (isExpense(t)) cardAccts[k].spend += t.amount;
}
const cardList = Object.entries(cardAccts).sort((a, b) => (b[1].fees + b[1].spend) - (a[1].fees + a[1].spend));
host.innerHTML = `
<div class="grid g-3" style="margin-bottom:16px;">
${kpi('Paid in fees & interest', fmtBig(feeTotal), `${fees.length} charges across all cards & lines`, col.fee())}
${kpi('Card payments made', fmtBig(cardPayTotal), `${cardPay.length} payments to your own cards`, col.internal())}
${kpi('Interest alone', fmtBig(byFee['Interest'] ? byFee['Interest'].value : 0), Math.round((byFee['Interest'] ? byFee['Interest'].value : 0) / feeTotal * 100) + '% of all charges', col.spend())}
</div>
<div class="grid g-2-1">
<div class="card"><h3>What the bank charged you<span class="pill">fees & interest by type</span></h3><div id="cd-fees"></div></div>
<div class="card"><h3>Charges by month<span class="pill">CLP / month</span></h3><div class="chart-host" id="cd-month"></div></div>
</div>
<div class="card" style="margin-top:16px;"><h3>Per card & line<span class="pill">spend + fees</span></h3>
<table class="btable"><thead><tr><th>Card / line</th><th>Txns</th><th>Spend on it</th><th>Fees charged</th><th>Fee rate</th></tr></thead><tbody id="cd-rows"></tbody></table>
</div>
`;
C.hbars($('#cd-fees'), feeArr.map(([label, d]) => ({
label, value: d.value, color: FEE_C[label] || col.mute(), sub: d.txns.length + ' charges · ' + Math.round(d.value / feeTotal * 100) + '%',
_txns: d.txns, _c: FEE_C[label] || col.mute()
})), { max: feeArr[0][1].value, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
C.monthlyBars($('#cd-month'), months, [{ key: 'f', label: 'Fees & interest', color: col.fee(), values: feeByM }], { height: 200 });
const tb = $('#cd-rows');
cardList.forEach(([name, c]) => {
const rate = c.spend ? c.fees / c.spend * 100 : null;
const tr = document.createElement('tr');
tr.innerHTML = `
<td><div class="bk-name"><span class="bk-dot" style="background:${col.fee()}"></span>${esc(name)}</div></td>
<td>${c.n}</td>
<td>${c.spend ? CLPk(c.spend) : '<span class="muted">—</span>'}</td>
<td style="color:${col.fee()}">${c.fees ? CLPk(c.fees) : '<span class="muted">—</span>'}</td>
<td class="muted">${rate != null ? Math.round(rate) + '%' : '—'}</td>`;
tb.appendChild(tr);
});
}
// ============================================================ RHYTHM / CALENDAR
function renderRhythm(host) {
const tx = TX();
const spend = tx.filter(t => isExpense(t) || isFee(t));
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// weekday × (none) totals
const wdAmt = [0, 0, 0, 0, 0, 0, 0], wdTx = [[], [], [], [], [], [], []];
for (const t of spend) { const w = new Date(t.date + 'T00:00:00').getDay(); wdAmt[w] += t.amount; wdTx[w].push(t); }
// month × day-of-month heatmap for spending
const months = MT.months;
const grid = {}; months.forEach(m => grid[m] = {});
for (const t of spend) { const day = +t.date.slice(8, 10); (grid[t.ym][day] = grid[t.ym][day] || { v: 0, raw: [] }); grid[t.ym][day].v += t.amount; grid[t.ym][day].raw.push(t); }
// busiest weekday
const peakWd = wdAmt.indexOf(Math.max(...wdAmt));
const totalSpend = sum(spend);
const weekendShare = Math.round((wdAmt[0] + wdAmt[6]) / totalSpend * 100);
const WDLONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
host.innerHTML = `
<div class="grid g-3" style="margin-bottom:16px;">
${kpi('Busiest spending day', WDLONG[peakWd], CLPk(wdAmt[peakWd]) + ' total spent', col.spend())}
${kpi('Weekend share', weekendShare + '%', 'of spending lands SatSun', col.fee())}
${kpi('Active days', Object.values(grid).reduce((s, m) => s + Object.keys(m).length, 0), 'days with at least one purchase', col.income())}
</div>
<div class="card"><h3>Spending by day of month<span class="pill">darker = more spent</span></h3><div class="chart-host" id="rh-heat" style="overflow-x:auto;"></div>
<div class="hm-scale"><span>less</span><div class="sw"><i style="background:color-mix(in oklab, var(--c-spend) 12%, var(--bg))"></i><i style="background:color-mix(in oklab, var(--c-spend) 40%, var(--bg))"></i><i style="background:color-mix(in oklab, var(--c-spend) 70%, var(--bg))"></i><i style="background:var(--c-spend)"></i></div><span>more</span></div>
</div>
<div class="card" style="margin-top:16px;"><h3>Which weekday do you spend?<span class="pill">total by day of week</span></h3><div id="rh-wd"></div></div>
`;
// heatmap: rows = months, cols = days 1..31
const days = Array.from({ length: 31 }, (_, i) => i + 1);
const rows = months.map(m => ({
label: MT.MONTH_LABEL(m),
cells: days.map(d => {
const cell = grid[m][d];
return { v: cell ? cell.v : 0, raw: cell ? cell.raw : [], title: cell ? MT.MONTH_LABEL(m) + ' ' + d + ' · ' + CLP(cell.v) : '' };
})
}));
const heatHost = $('#rh-heat');
heatHost.style.setProperty('--cols', 31);
heatHost.querySelectorAll('.hm-row').forEach(r => r.style.setProperty('--cols', 31));
C.heatmap(heatHost, rows, days.map(d => d % 2 ? d : ''), { color: col.spend() });
heatHost.querySelectorAll('.hm-row').forEach(r => r.style.gridTemplateColumns = '64px repeat(31, 1fr)');
// weekday bars
C.hbars($('#rh-wd'), WDLONG.map((w, i) => ({
label: w, value: wdAmt[i], color: (i === 0 || i === 6) ? col.fee() : col.spend(),
_txns: wdTx[i], _c: (i === 0 || i === 6) ? col.fee() : col.spend()
})), { max: Math.max(...wdAmt), onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
}
// ============================================================ LEDGER EXPLORER
function renderLedger(host) {
const tx = TX().slice();
if (!host._state) host._state = { q: '', flow: 'all', bank: 'all', sort: 'date', dir: -1, limit: 60 };
const st = host._state;
const FLOW_C = { income: col.income(), inter_person: col.person(), expense: col.spend(), fee: col.fee(), self_transfer: col.internal(), credit_line: col.internal(), card_payment: col.internal() };
const FLOW_LABEL = { income: 'Income', inter_person: 'Person', expense: 'Spending', fee: 'Fee', self_transfer: 'Self-transfer', credit_line: 'Credit line', card_payment: 'Card payment' };
host.innerHTML = `
<div class="lx-controls">
<div class="lx-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>
<input id="lx-q" placeholder="Search description, counterparty, bank…" value="${esc(st.q)}" /></div>
<select class="lx-select" id="lx-flow"></select>
<select class="lx-select" id="lx-bank"></select>
<span class="lx-count" id="lx-count"></span>
</div>
<div class="lx-table-wrap"><table class="lx-table"><thead><tr>
<th data-s="date">Date</th><th data-s="desc">Description</th><th data-s="bank">Account</th>
<th data-s="flow">Type</th><th class="r" data-s="amount">Amount</th>
</tr></thead><tbody id="lx-rows"></tbody></table><div id="lx-more"></div></div>
`;
// flow select
const flowOpts = ['all', 'income', 'inter_person', 'expense', 'fee', 'self_transfer', 'credit_line', 'card_payment'];
$('#lx-flow').innerHTML = flowOpts.map(f => `<option value="${f}"${st.flow === f ? ' selected' : ''}>${f === 'all' ? 'All types' : FLOW_LABEL[f]}</option>`).join('');
$('#lx-bank').innerHTML = `<option value="all">All banks</option>` + MT.banks.map(b => `<option value="${esc(b)}"${st.bank === b ? ' selected' : ''}>${esc(b)}</option>`).join('');
function apply() {
let rows = tx;
const q = st.q.trim().toLowerCase();
if (q) rows = rows.filter(t => (t.description || '').toLowerCase().includes(q) || (t.counterparty || '').toLowerCase().includes(q) || t.bank.toLowerCase().includes(q));
if (st.flow !== 'all') rows = rows.filter(t => t.flow_type === st.flow);
if (st.bank !== 'all') rows = rows.filter(t => t.bank === st.bank);
rows.sort((a, b) => {
let av, bv;
if (st.sort === 'date') { av = a.date; bv = b.date; }
else if (st.sort === 'amount') { av = a.amount; bv = b.amount; }
else if (st.sort === 'bank') { av = a.bank; bv = b.bank; }
else if (st.sort === 'flow') { av = a.flow_type; bv = b.flow_type; }
else { av = a.description || ''; bv = b.description || ''; }
return (av < bv ? -1 : av > bv ? 1 : 0) * st.dir;
});
$('#lx-count').textContent = rows.length + ' of ' + tx.length + ' transactions';
const shown = rows.slice(0, st.limit);
$('#lx-rows').innerHTML = shown.map(t => `
<tr><td class="lx-date">${t.date}</td>
<td class="lx-desc" title="${esc(t.description)}">${esc(MT.cleanDesc(t.description) || t.counterparty || '—')}</td>
<td style="white-space:nowrap;color:var(--ink-mute)">${esc(t.bank)} <span class="lx-pdf">${MT.acctShort(t.doc_type)}${t.last4 ? ' ··' + t.last4 : ''}</span></td>
<td><span class="lx-flow-tag"><i style="background:${FLOW_C[t.flow_type] || col.mute()}"></i>${FLOW_LABEL[t.flow_type] || t.flow_type}</span></td>
<td class="r lx-amt ${t.direction === 'credit' ? 'cr' : 'db'}">${t.direction === 'credit' ? '+' : ''}${CLP(t.amount)}</td></tr>`).join('');
$('#lx-more').innerHTML = rows.length > st.limit ? `<div class="lx-more">Show ${Math.min(60, rows.length - st.limit)} more (${rows.length - st.limit} hidden)</div>` : '';
const more = $('#lx-more').querySelector('.lx-more');
if (more) more.addEventListener('click', () => { st.limit += 60; apply(); });
}
$('#lx-q').addEventListener('input', e => { st.q = e.target.value; st.limit = 60; apply(); });
$('#lx-flow').addEventListener('change', e => { st.flow = e.target.value; st.limit = 60; apply(); });
$('#lx-bank').addEventListener('change', e => { st.bank = e.target.value; st.limit = 60; apply(); });
host.querySelectorAll('.lx-table th[data-s]').forEach(th => th.addEventListener('click', () => {
const s = th.dataset.s; if (st.sort === s) st.dir *= -1; else { st.sort = s; st.dir = s === 'date' || s === 'amount' ? -1 : 1; } apply();
}));
apply();
}
// ============================================================ DATA QUALITY
function renderQuality(host) {
const tx = TX();
const raw = MT.raw;
const months = MT.months;
// statement coverage: account -> set of months covered (from period_start..period_end)
const acctCov = {};
for (const s of raw.statements) {
const key = MT.bankName(s.bank) + ' · ' + MT.acctShort(s.doc_type) + (s.account_last4 ? ' ··' + s.account_last4 : '');
(acctCov[key] = acctCov[key] || { months: new Set(), docs: 0, bank: MT.bankName(s.bank) });
acctCov[key].docs++;
if (s.period_start && s.period_end) {
let c = new Date(s.period_start.slice(0, 7) + '-01T00:00:00');
const end = new Date(s.period_end.slice(0, 7) + '-01T00:00:00');
while (c <= end) { acctCov[key].months.add(c.toISOString().slice(0, 7)); c.setMonth(c.getMonth() + 1); }
}
}
const accts = Object.entries(acctCov).sort((a, b) => b[1].docs - a[1].docs);
// metrics
const withBal = tx.filter(t => t.balance != null).length;
const withPeriod = raw.statements.filter(s => s.period_start && s.period_end).length;
const classified = tx.filter(t => t.flow_type !== 'expense' || MT.spendCategory(t.description) !== 'Other purchases').length;
const otherSpend = tx.filter(t => t.flow_type === 'expense' && MT.spendCategory(t.description) === 'Other purchases').length;
const namedCp = tx.filter(t => t.counterparty).length;
const pct = (a, b) => Math.round(a / b * 100);
function metric(v, total, label, c) {
return `<div class="qstat"><div class="qv">${pct(v, total)}%</div><div class="ql">${label}</div><div class="qbar"><i style="width:${pct(v, total)}%;background:${c}"></i><i style="width:${100 - pct(v, total)}%;background:var(--line)"></i></div></div>`;
}
host.innerHTML = `
<div class="qstats">
${metric(withPeriod, raw.statements.length, 'Statements with a clear period', col.income())}
${metric(withBal, tx.length, 'Transactions with a running balance', col.fee())}
${metric(namedCp, tx.length, 'Transactions with a named counterparty', col.person())}
${metric(tx.length - otherSpend, tx.length, 'Transactions confidently categorized', col.income())}
</div>
<div class="card"><h3>Statement coverage<span class="pill">${accts.length} accounts × ${months.length} months</span></h3>
<div class="cov-grid" id="cov-grid"></div>
<div class="hm-scale"><span style="color:var(--ink-dim)">Each filled cell = a statement covers that month. Gaps = months with no statement on file.</span></div>
</div>
<div class="grid g-2" style="margin-top:16px;">
<div class="insight"><div class="i-ic">!</div><div><b>${otherSpend}</b> purchases (${pct(otherSpend, tx.length)}%) couldn't be matched to a spending category and sit in "Other purchases" the raw statements carry no merchant field, so these are inferred from free-text descriptions.</div></div>
<div class="insight"><div class="i-ic">i</div><div>Credit-card statements report transactions but <b>not</b> running balances, so the Balances tab covers cash and credit-line accounts only.</div></div>
</div>
`;
// coverage grid
const grid = $('#cov-grid');
grid.style.setProperty('--cols', months.length);
const head = document.createElement('div'); head.className = 'cov-row cov-head'; head.style.setProperty('--cols', months.length);
head.innerHTML = `<div class="cov-rowlab"></div>` + months.map(m => `<div class="cov-collab">${MT.MONTH_LABEL(m)}</div>`).join('');
grid.appendChild(head);
accts.forEach(([name, a]) => {
const row = document.createElement('div'); row.className = 'cov-row'; row.style.setProperty('--cols', months.length);
let html = `<div class="cov-rowlab"><span class="cov-dot" style="background:${col.income()}"></span><div>${esc(name)}<div class="cov-meta">${a.docs} stmts · ${a.months.size} mo</div></div></div>`;
months.forEach(m => { html += `<div class="cov-cell${a.months.has(m) ? ' has' : ''}" title="${esc(name)} · ${MT.MONTH_LABEL(m)}${a.months.has(m) ? ' — covered' : ' — no statement'}"></div>`; });
row.innerHTML = html;
grid.appendChild(row);
});
}
// ============================================================ PLATFORMS
const RAILS = [
['MercadoPago', /MERCADO ?PAGO|MERPAGO|MERPAG|MP —|MP\*/i, '#6fd0ff'],
['MACH', /\bMACH\b/i, '#2ee6a6'],
['WebPay', /WEBPAY|WEB PAY/i, '#b98cff'],
['PayU', /\bPAYU\b/i, '#ffc24b'],
['Rappi', /RAPPI/i, '#ff8f6b'],
['Uber', /\bUBER\b|UBER ?EATS/i, '#5fd0a0'],
['SumUp', /SUMUP/i, '#8c9eff'],
['Payscan', /PAYSCAN/i, '#ff5f73'],
['Tuu', /\bTUU\b|TUU\*/i, '#d0d6e0'],
['Fintoc', /FINTOC/i, '#46c6ba'],
['Servipag', /SERVIPAG/i, '#ffd45a'],
['Sencillito', /SENCILLITO/i, '#c9a6ff'],
['Khipu', /KHIPU/i, '#7CFFB2'],
['Kushki', /KUSHKI/i, '#FF8FA3'],
['OneClick', /ONECLICK|ONE CLICK/i, '#6fd0ff'],
['Compraqui', /COMPRAQUI/i, '#ff8f6b']
];
function railOf(t) {
if (t.platform) { for (const [n, re] of RAILS) if (re.test(t.platform)) return n; }
for (const [n, re] of RAILS) if (re.test(t.description || '')) return n;
return null;
}
const railColor = name => (RAILS.find(r => r[0] === name) || [, , col.mute()])[2];
function renderPlatforms(host) {
const tx = TX();
const railTx = {};
for (const t of tx) { const r = railOf(t); if (!r) continue; (railTx[r] = railTx[r] || []).push(t); }
let rails = Object.entries(railTx).map(([name, txns]) => {
const out = txns.filter(t => t.direction === 'debit'), inn = txns.filter(t => t.direction === 'credit');
return { name, txns, n: txns.length, amt: sum(txns), out: sum(out), in: sum(inn), color: railColor(name) };
}).sort((a, b) => b.amt - a.amt);
const totalAmt = rails.reduce((s, r) => s + r.amt, 0);
const totalN = rails.reduce((s, r) => s + r.n, 0);
const byCount = rails.slice().sort((a, b) => b.n - a.n);
const outTotal = rails.reduce((s, r) => s + r.out, 0), inTotal = rails.reduce((s, r) => s + r.in, 0);
host.innerHTML = `
<div class="grid g-3" style="margin-bottom:16px;">
${kpi('Payment rails seen', rails.length, `across ${totalN} transactions`, col.person())}
${kpi('Most used', byCount.length ? esc(byCount[0].name) : '—', byCount.length ? byCount[0].n + ' transactions' : '', byCount.length ? byCount[0].color : col.mute())}
${kpi('Routed through platforms', fmtBig(totalAmt), '~' + Math.round(totalN / tx.length * 100) + '% of all transactions', col.income())}
</div>
<div class="grid g-2-1">
<div class="card"><h3>Platforms by value<span class="pill">money moved per rail</span></h3><div id="pf-amt"></div></div>
<div class="card"><h3>By transaction count<span class="pill">how often used</span></h3><div id="pf-cnt"></div></div>
</div>
<div class="insight" style="margin-top:16px;"><div class="i-ic">i</div><div>Rails are detected from the <b>platform</b> field and transaction descriptions, so this covers the ~${Math.round(totalN / tx.length * 100)}% of transactions that name a processor. <b>Servipag</b> and <b>MACH</b> move large sums in a few bill-payments, while <b>MercadoPago</b> and <b>PayU</b> appear in many small card purchases.</div></div>
`;
C.hbars($('#pf-amt'), rails.map(r => ({
label: r.name, value: r.amt, color: r.color,
sub: r.n + ' txns' + (r.in && r.out ? ' · in+out' : r.in ? ' · money in' : ' · purchases'),
_txns: r.txns, _c: r.color
})), { max: rails[0] ? rails[0].amt : 1, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
C.hbars($('#pf-cnt'), byCount.map(r => ({
label: r.name, value: r.n, color: r.color, sub: CLPk(r.amt), _txns: r.txns, _c: r.color
})), { max: byCount[0] ? byCount[0].n : 1, money: false, onHover: (e, d) => window.MTtip.rows(e, d.label, d._c, d._txns) });
}
// register all
D.register('balances', renderBalances);
D.register('people', renderPeople);
D.register('cards', renderCards);
D.register('rhythm', renderRhythm);
D.register('ledger', renderLedger);
D.register('quality', renderQuality);
D.register('platforms', renderPlatforms);
})();

348
web/engine.js Normal file
View File

@ -0,0 +1,348 @@
/* money-trace · data engine
Loads ledger.json, normalizes counterparties, categorizes spend,
resolves internal-flow endpoints, and builds a layered node/link graph.
Exposes window.MT. Pure vanilla JS no deps. */
(function () {
'use strict';
// ---------- formatting ----------
const CLP = n => '$' + Math.round(n).toLocaleString('es-CL');
const CLPk = n => {
const a = Math.abs(n);
if (a >= 1e6) return '$' + (n / 1e6).toLocaleString('es-CL', { maximumFractionDigits: 1 }) + 'M';
if (a >= 1e3) return '$' + Math.round(n / 1e3).toLocaleString('es-CL') + 'k';
return CLP(n);
};
const MONTH_LABEL = ym => {
const [y, m] = ym.split('-');
const names = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
return names[+m - 1] + " '" + y.slice(2);
};
// ---------- flow categories (visual spine) ----------
const CAT = {
income: { key: 'income', kind: 'real', label: 'Income' },
inter_in: { key: 'inter_in', kind: 'real', label: 'From a person' },
inter_out: { key: 'inter_out', kind: 'real', label: 'To a person' },
expense: { key: 'expense', kind: 'real', label: 'Spending' },
fee: { key: 'fee', kind: 'real', label: 'Fees & interest' },
internal_card: { key: 'internal_card', kind: 'internal', label: 'Card payment' },
internal_line: { key: 'internal_line', kind: 'internal', label: 'Credit-line sweep' },
internal_self: { key: 'internal_self', kind: 'internal', label: 'Between my accounts' }
};
// ---------- counterparty normalization (income + people) ----------
function normIncome(cp) {
if (!cp) return 'Other income';
const u = cp.toUpperCase();
if (u.includes('VINOS LABERINTO') || u.includes('LABERINTO')) return 'Vinos Laberinto';
if (u.includes('HEIDI') || u.includes('ANDRESEN MULLER HEIDI')) return 'Heidi Andresen';
if (u.includes('MURALLA')) return 'Muralla SPA';
if (u.startsWith('SDE ') || u.includes('MERCADO PAGO') || u.includes('MERCADOPAGO')) return 'Deposits & checks';
if (u.includes('EMA BLANCA') || u.includes('EMA MULLER')) return 'Ema Muller';
if (u.includes('RETAMAL')) return 'Bryan Retamal';
if (u.includes('MARCELINO')) return 'Marcelino Fuentes';
// title-case fallback, trimmed
return titleCase(cp);
}
function normPerson(cp) {
if (!cp) return 'Unknown person';
const u = cp.toUpperCase();
if (u.includes('VICENTE') && u.includes('TIRAD')) return 'Vicente (self)';
if (u.includes('MURALLA')) return 'Muralla SPA';
if (u.includes('FINTOC')) return 'Fintoc';
if (u.includes('ARISMENDI')) return 'Cristian Arismendi';
if (u.includes('BRUNA')) return 'Darwin Bruna';
if (u.includes('HEIDI') || u.includes('ANDRESEN MULLER HEIDI')) return 'Heidi Andresen';
if (u.includes('BUSTOS')) return 'G. Bustos';
if (u === 'RUT' || u.includes('MI CUENTA')) return 'Other transfer';
return titleCase(cp);
}
function titleCase(s) {
return String(s).toLowerCase().replace(/\b\w/g, c => c.toUpperCase()).trim().slice(0, 26);
}
// ---------- spend categorization (expense has no counterparty) ----------
const SPEND_RULES = [
['Debt & loans', /DEUDA|CIERRE DE CUENTA|REPACTAC|PRESTAMO|AVANCE (EN |DE )?EFECTIVO|CUOTA CREDITO/i],
['Cash withdrawals', /RETIRO ATM|RETIRO (DE )?EFECTIVO|GIRO (EN )?CAJERO|RETIRO CAJERO|CARGA DE TRANSFERENCIA/i],
['Utilities & bills',/SERVIPAG|SENCILLITO|\bENEL\b|COMPRAQUI|AGUAS ANDINA|METROGAS|GASCO|CHILECTRA|ESVAL|PAGO DE CUENTA|CUENTA DE LUZ|VTR|MOVISTAR|ENTEL|CLARO|WOM/i],
['Groceries', /SANTA ISABEL|STA ISABEL|JUMBO|\bLIDER\b|HIP LIDER|TOTTUS|UNIMARC|OXXO|MINIMARKET|MAYORISTA|NORTE VERDE|COMERCIAL|ABARROTE|MERCADO\b/i],
['Food & dining', /KFC|MC ?DONALD|BURGER|FORK|DELICIA|RESTAU|CABROS|NARESH|TENDERINO|PIZZA|SUSHI|CAFE|DONDE|COMIDA|EMPANAD|MACKENNA|SENLE|FABRICA|CALETA|IRIS|UBER ?EATS|OISHII|RAPPI|PEDIDOSYA/i],
['Fuel & transport',/COPEC|SHELL|PETROBRAS|PETROBA|\bUBER\b|CABIFY|DIDI|METRO\b|\bBIP\b|ESTACION|PEAJE|PARKING|AUTOPISTA/i],
['Health & pharmacy',/FARMACIA|CRUZ VERDE|SALCOBRAND|AHUMADA|NUTRAFIT|CLINICA|DENTAL|DENTIMAGEN|MEDIC|ULTRA SOLIDAR/i],
['Subscriptions & web',/HETZNER|SITEGROUND|GOOGLE|SPOTIFY|NETFLIX|APPLE\.COM|OPENAI|ANTHROPIC|MICROSOFT|\bAWS\b|HOSTING|GODADDY|NAMECHEAP|CLOUD|GITHUB|VERCEL|NOTION|FIGMA|ADOBE/i],
['Shopping & retail',/FALABELLA|RIPLEY|PARIS|ALIEXPRESS|AMAZON|FOTOGRAFO|TIENDA|SODIMAC|EASY|SHEIN|LOKAL|ELECTRONICA|BACKSTAGE|RETAIL|\bDP\b/i],
['Government & docs',/LICENCIA|IMPUESTO|REGISTRO CIVIL|MUNICIPAL|TESORERIA|NOTARIA|PERMISO/i],
['Wallets & online', /MERCADO ?PAGO|MERPAGO|\bMACH\b|MERCADOPAGO|WEBPAY|\bPAYU\b|\bTUU\b|SUMUP|PAYSCAN|COMPRA POR INTERNET|COMPRA NACIONAL|TARJETA (DIGITAL|VIRTUAL)|\bMP\b|\bCV\b|CUOTAS|ONECLICK|KUSHKI|YAPO|TARJETA ·/i]
];
function spendCategory(desc) {
const d = desc || '';
for (const [name, re] of SPEND_RULES) if (re.test(d)) return name;
return 'Other purchases';
}
// clean a merchant/desc for tooltip display
function cleanDesc(desc) {
if (!desc) return '—';
return desc.replace(/^(MERPAGO|MERCADOPAGO|PAYSCAN|SUMUP|TUU|MACH)\*?/i, '').trim() || desc;
}
// ---------- bank display ----------
function bankName(b) {
return ({
'Tarjeta_Spin': 'Spin', 'Tarjeta Spin': 'Spin',
'Banco_de_Chile': 'Banco de Chile', 'Ita': 'Itaú'
})[b] || b;
}
const isCardType = dt => dt === 'tarjeta_credito' || dt === 'linea_credito';
function acctShort(dt) {
return ({ cuenta_corriente: 'Cta Cte', cuenta_vista: 'Cta Vista', linea_credito: 'Línea de crédito', tarjeta_credito: 'Tarjeta' })[dt] || dt;
}
// ---------- load ----------
let RAW = null, TX = [], MONTHS = [], BANKS = [], ACCT_LAST4 = new Set();
async function load() {
const res = await fetch('ledger.json');
RAW = await res.json();
TX = [];
for (const s of RAW.statements) {
for (const t of (s.transactions || [])) {
if (!t.date) continue;
TX.push({
date: t.date,
ym: t.date.slice(0, 7),
amount: t.amount,
direction: t.direction,
description: t.description || '',
counterparty: t.counterparty || null,
counterparty_rut: t.counterparty_rut || null,
platform: t.platform || null,
balance: (t.balance === 0 || t.balance == null) ? null : t.balance,
flow_type: t.flow_type,
internal: !!t.internal,
bank: bankName(s.bank),
rawBank: s.bank,
doc_type: s.doc_type,
last4: s.account_last4 || null,
pdf: s.pdf_url || null
});
if (s.account_last4) ACCT_LAST4.add(s.account_last4);
}
}
MONTHS = [...new Set(TX.map(t => t.ym))].sort();
BANKS = [...new Set(TX.map(t => t.bank))].sort();
return { months: MONTHS, banks: BANKS, totals: RAW.real_totals };
}
// node id helpers
const acctId = t => 'acc:' + t.bank + ':' + (t.last4 || t.doc_type);
const cardId = t => 'card:' + t.bank + ':' + (t.last4 || t.doc_type);
function acctLabel(t) { return { id: acctId(t), col: 1, kind: 'account', bank: t.bank, label: t.bank, sub: acctShort(t.doc_type) + (t.last4 ? ' ··' + t.last4 : '') }; }
function cardLabel(t) { return { id: cardId(t), col: 2, kind: 'card', bank: t.bank, label: t.bank, sub: acctShort(t.doc_type) + (t.last4 ? ' ··' + t.last4 : '') }; }
// find a same-bank node from the live node map, preferring a real (non-synthetic) one
function pickSameBank(nodes, bank, col) {
let synth = null;
for (const n of nodes.values()) {
if (n.bank === bank && n.col === col) {
if (!/:(pago|linea|cta)$/.test(n.id)) return n;
synth = synth || n;
}
}
return synth;
}
// parse trailing account digits, match to my known last4 set
function matchOwnAccount(desc, exclude) {
const digits = (desc.match(/\d{3,}/g) || []);
for (const d of digits) {
for (const l4 of ACCT_LAST4) {
if (l4 && l4 !== exclude && d.endsWith(l4)) return l4;
}
}
return null;
}
// ---------- build graph for a filter set ----------
function build(opts) {
opts = opts || {};
const months = opts.months ? new Set(opts.months) : null; // null = all
const banks = opts.banks ? new Set(opts.banks) : null;
const hideInternal = !!opts.hideInternal;
let nodes = new Map(); // id -> node
let links = new Map(); // key -> link
const ensure = (desc) => { if (!nodes.has(desc.id)) nodes.set(desc.id, Object.assign({ value: 0, inLinks: [], outLinks: [] }, desc)); return nodes.get(desc.id); };
function addLink(srcId, tgtId, amount, cat, tx) {
const key = srcId + '→' + tgtId + '#' + cat;
let L = links.get(key);
if (!L) { L = { source: srcId, target: tgtId, value: 0, cat, kind: CAT[cat].kind, txns: [] }; links.set(key, L); }
L.value += amount; L.txns.push(tx);
}
for (const t of TX) {
if (months && !months.has(t.ym)) continue;
if (banks && !banks.has(t.bank)) continue;
const ft = t.flow_type;
const internal = ['self_transfer', 'credit_line', 'card_payment'].includes(ft);
if (hideInternal && internal) continue;
const isCard = isCardType(t.doc_type);
const selfNodeDesc = isCard ? cardLabel(t) : acctLabel(t);
if (ft === 'income') {
const src = ensure({ id: 'inc:' + normIncome(t.counterparty), col: 0, kind: 'source', label: normIncome(t.counterparty), sub: 'income' });
const acc = ensure(selfNodeDesc);
addLink(src.id, acc.id, t.amount, 'income', t);
} else if (ft === 'inter_person') {
const person = { id: 'per:' + normPerson(t.counterparty), kind: 'person' };
if (t.direction === 'credit') {
const src = ensure({ id: person.id, col: 0, kind: 'person', label: normPerson(t.counterparty), sub: 'transfer in' });
const acc = ensure(selfNodeDesc);
addLink(src.id, acc.id, t.amount, 'inter_in', t);
} else {
const acc = ensure(selfNodeDesc);
const tgt = ensure({ id: person.id, col: 3, kind: 'person', label: normPerson(t.counterparty), sub: 'transfer out' });
addLink(acc.id, tgt.id, t.amount, 'inter_out', t);
}
} else if (ft === 'expense') {
const acc = ensure(selfNodeDesc);
const cat = spendCategory(t.description);
const tgt = ensure({ id: 'spend:' + cat, col: 3, kind: 'spend', label: cat, sub: 'spending' });
addLink(acc.id, tgt.id, t.amount, 'expense', t);
} else if (ft === 'fee') {
const acc = ensure(selfNodeDesc);
const tgt = ensure({ id: 'spend:Fees & interest', col: 3, kind: 'fee', label: 'Fees & interest', sub: 'bank fees' });
addLink(acc.id, tgt.id, t.amount, 'fee', t);
} else if (ft === 'card_payment') {
// resolve account (col1) -> card (col2), within bank where possible
let accDesc, cardDesc;
if (isCard) {
cardDesc = cardLabel(t);
const peer = pickSameBank(nodes, t.bank, 1);
accDesc = peer || { id: 'acc:' + t.bank + ':pago', col: 1, kind: 'account', bank: t.bank, label: t.bank, sub: 'account' };
} else {
accDesc = acctLabel(t);
const peer = pickSameBank(nodes, t.bank, 2);
cardDesc = peer || { id: 'card:' + t.bank + ':pago', col: 2, kind: 'card', bank: t.bank, label: t.bank, sub: 'Tarjeta' };
}
const a = ensure(accDesc), c = ensure(cardDesc);
addLink(a.id, c.id, t.amount, 'internal_card', t);
} else if (ft === 'credit_line') {
// cuenta (col1) <-> linea (col2)
let accDesc, lineDesc;
if (t.doc_type === 'linea_credito') {
lineDesc = cardLabel(t);
const l4 = matchOwnAccount(t.description, t.last4);
accDesc = pickSameBank(nodes, t.bank, 1) || { id: 'acc:' + t.bank + ':' + (l4 || 'cta'), col: 1, kind: 'account', bank: t.bank, label: t.bank, sub: l4 ? 'Cta ··' + l4 : 'Cta Cte' };
} else {
accDesc = acctLabel(t);
lineDesc = pickSameBank(nodes, t.bank, 2) || { id: 'card:' + t.bank + ':linea', col: 2, kind: 'card', bank: t.bank, label: t.bank, sub: 'Línea de crédito' };
}
const a = ensure(accDesc), l = ensure(lineDesc);
addLink(a.id, l.id, t.amount, 'internal_line', t);
} else if (ft === 'self_transfer') {
const anchor = ensure(acctLabel(t));
const l4 = matchOwnAccount(t.description, t.last4);
let other = anchor;
if (l4) {
let found = null;
for (const n of nodes.values()) if (n.kind === 'account' && n.id.endsWith(':' + l4)) { found = n; break; }
other = found || ensure({ id: 'acc:own:' + l4, col: 1, kind: 'account', bank: 'My account', label: 'My account', sub: '··' + l4 });
}
// unmatched transfers loop back on the same account (visible self-cycle)
if (t.direction === 'debit') addLink(anchor.id, other.id, t.amount, 'internal_self', t);
else addLink(other.id, anchor.id, t.amount, 'internal_self', t);
}
}
// ---- collapse small tail nodes (col0 sources/people, col3 people) into "Other" ----
const COLLAPSE_T = 220000;
const prelim = {};
for (const L of links.values()) {
(prelim[L.source] = prelim[L.source] || { in: 0, out: 0 }).out += L.value;
(prelim[L.target] = prelim[L.target] || { in: 0, out: 0 }).in += L.value;
}
const idMap = {}; const otherDescs = {};
const OTHER_ACC = { id: 'acc:Other accounts', col: 1, kind: 'account', bank: 'Other', label: 'Other accounts', sub: 'unresolved / small' };
const OTHER_CARD = { id: 'card:Other cards', col: 2, kind: 'card', bank: 'Other', label: 'Other cards', sub: 'small' };
for (const [id, node] of nodes) {
const pv = prelim[id] || { in: 0, out: 0 };
const val = Math.max(pv.in, pv.out);
// unresolved / phantom accounts created by one-sided internal records — merge regardless of size
if (node.col === 1 && node.kind === 'account' && /:(pago|cta)$/.test(id)) { idMap[id] = OTHER_ACC.id; otherDescs[OTHER_ACC.id] = OTHER_ACC; continue; }
if (val >= COLLAPSE_T) continue;
if (node.col === 0 && (node.kind === 'source' || node.kind === 'person')) {
idMap[id] = 'inc:Other income';
otherDescs['inc:Other income'] = { id: 'inc:Other income', col: 0, kind: 'source', label: 'Other income', sub: 'small sources' };
} else if (node.col === 3 && node.kind === 'person') {
idMap[id] = 'per:Other people';
otherDescs['per:Other people'] = { id: 'per:Other people', col: 3, kind: 'person', label: 'Other people', sub: 'small transfers' };
} else if (node.col === 3 && node.kind === 'spend') {
idMap[id] = 'spend:Other purchases';
otherDescs['spend:Other purchases'] = { id: 'spend:Other purchases', col: 3, kind: 'spend', label: 'Other purchases', sub: 'spending' };
} else if (node.col === 1 && node.kind === 'account' && val < 150000) {
idMap[id] = OTHER_ACC.id; otherDescs[OTHER_ACC.id] = OTHER_ACC;
} else if (node.col === 2 && node.kind === 'card' && val < 110000) {
idMap[id] = OTHER_CARD.id; otherDescs[OTHER_CARD.id] = OTHER_CARD;
}
}
if (Object.keys(idMap).length) {
const remap = id => idMap[id] || id;
const links2 = new Map();
for (const L of links.values()) {
const s = remap(L.source), t = remap(L.target);
if (s === t && CAT[L.cat].kind !== 'internal') continue;
const key = s + '→' + t + '#' + L.cat;
let n = links2.get(key);
if (!n) { n = { source: s, target: t, value: 0, cat: L.cat, kind: L.kind, txns: [] }; links2.set(key, n); }
n.value += L.value; n.txns.push(...L.txns);
}
const nodes2 = new Map();
const descOf = id => otherDescs[id] || nodes.get(id);
for (const L of links2.values()) for (const id of [L.source, L.target]) {
if (!nodes2.has(id)) nodes2.set(id, Object.assign({ value: 0, inLinks: [], outLinks: [] }, descOf(id)));
}
nodes = nodes2; links = links2;
}
// wire link refs onto nodes, compute values
const linkArr = [...links.values()];
for (const L of linkArr) {
const s = nodes.get(L.source), tg = nodes.get(L.target);
if (!s || !tg) continue;
s.outLinks.push(L); tg.inLinks.push(L);
}
for (const n of nodes.values()) {
const inSum = n.inLinks.reduce((a, l) => a + l.value, 0);
const outSum = n.outLinks.reduce((a, l) => a + l.value, 0);
n.value = Math.max(inSum, outSum);
n.inSum = inSum; n.outSum = outSum;
}
// totals (real only)
let realIn = 0, realOut = 0, internalAmt = 0;
for (const L of linkArr) {
if (L.cat === 'income' || L.cat === 'inter_in') realIn += L.value;
else if (L.cat === 'expense' || L.cat === 'fee' || L.cat === 'inter_out') realOut += L.value;
else internalAmt += L.value;
}
return {
nodes: [...nodes.values()].filter(n => n.value > 0),
links: linkArr.filter(l => nodes.has(l.source) && nodes.has(l.target)),
totals: { realIn, realOut, internal: internalAmt, net: realIn - realOut }
};
}
window.MT = {
load, build,
CLP, CLPk, MONTH_LABEL, cleanDesc, CAT,
spendCategory, normIncome, normPerson, bankName, isCardType, acctShort,
get months() { return MONTHS; },
get banks() { return BANKS; },
get tx() { return TX; },
get raw() { return RAW; }
};
})();

191
web/index.html Normal file
View File

@ -0,0 +1,191 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>money-trace · ledger</title>
<style>
:root{
--bg:#13131a; --panel:#1b1b24; --line:#2a2a37; --txt:#e7e7ee; --mut:#8a8a9a;
--in:#5ec48a; --out:#d98a8a; --accent:#c8b5d1; --chip:#23232e;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.45 ui-sans-serif,-apple-system,Segoe UI,Roboto,sans-serif}
header{position:sticky;top:0;z-index:5;background:var(--bg);border-bottom:1px solid var(--line);
padding:14px 20px;display:flex;gap:16px;align-items:center;flex-wrap:wrap}
h1{font-size:15px;font-weight:600;margin:0;letter-spacing:.3px}
h1 small{color:var(--mut);font-weight:400}
.stats{display:flex;gap:18px;margin-left:auto;font-variant-numeric:tabular-nums}
.stat b{display:block;font-size:11px;color:var(--mut);font-weight:500;text-transform:uppercase;letter-spacing:.5px}
.stat span{font-size:15px;font-weight:600}
.in{color:var(--in)} .out{color:var(--out)}
.controls{padding:12px 20px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;border-bottom:1px solid var(--line)}
input[type=search],select{background:var(--panel);border:1px solid var(--line);color:var(--txt);
padding:7px 11px;border-radius:8px;font-size:13px;outline:none}
input[type=search]{min-width:240px}
input:focus,select:focus{border-color:var(--accent)}
.seg{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
.seg button{background:var(--panel);border:0;color:var(--mut);padding:7px 13px;cursor:pointer;font-size:13px}
.seg button.on{background:var(--chip);color:var(--txt)}
.wrap{padding:0 20px 60px}
table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
thead th{position:sticky;top:0;background:var(--bg);text-align:left;color:var(--mut);font-weight:500;
font-size:11px;text-transform:uppercase;letter-spacing:.5px;padding:10px 10px;border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap}
tbody td{padding:9px 10px;border-bottom:1px solid var(--line)}
tbody tr:hover{background:#1e1e28}
.amt{text-align:right;font-weight:600}
.bal{text-align:right;color:var(--mut)}
.dir{font-size:11px;font-weight:600;letter-spacing:.4px}
.desc{max-width:520px}
.tag{display:inline-block;background:var(--chip);color:var(--mut);font-size:11px;padding:2px 7px;border-radius:6px;margin-right:6px}
a.pdf{color:var(--accent);text-decoration:none;border:1px solid var(--line);padding:3px 8px;border-radius:6px;font-size:12px;white-space:nowrap}
a.pdf:hover{border-color:var(--accent)}
.platform{color:var(--accent);font-size:11px;margin-left:6px}
.empty{color:var(--mut);padding:40px;text-align:center}
</style>
</head>
<body>
<header>
<h1>money&#8209;trace <small id="sub">· Santander</small></h1>
<div class="stats">
<div class="stat"><b>Movimientos</b><span id="s-count"></span></div>
<div class="stat"><b>Entradas reales</b><span class="in" id="s-in"></span></div>
<div class="stat"><b>Salidas reales</b><span class="out" id="s-out"></span></div>
<div class="stat"><b>Internos</b><span id="s-int" style="color:var(--mut)"></span></div>
<div class="stat"><b>Neto real</b><span id="s-net"></span></div>
</div>
</header>
<div class="controls">
<input type="search" id="q" placeholder="Buscar descripción, cuenta, RUT…">
<div class="seg" id="dir">
<button data-d="all" class="on">Todo</button>
<button data-d="credit">▲ Entradas</button>
<button data-d="debit">▼ Salidas</button>
</div>
<select id="bank"><option value="">Todos los bancos</option></select>
<select id="flow"><option value="">Todos los flujos</option></select>
<select id="type"><option value="">Todos los tipos</option></select>
<select id="acct"><option value="">Todas las cuentas</option></select>
<label style="display:flex;align-items:center;gap:6px;color:var(--mut);font-size:13px;cursor:pointer">
<input type="checkbox" id="hideInternal"> Ocultar internos</label>
</div>
<div class="wrap">
<table>
<thead><tr>
<th data-k="date">Fecha</th>
<th data-k="bank">Banco</th>
<th data-k="flow_type">Flujo</th>
<th data-k="account_last4">Cuenta</th>
<th data-k="direction">Dir</th>
<th data-k="amount" class="amt">Monto</th>
<th data-k="balance" class="bal">Saldo</th>
<th data-k="description">Descripción</th>
<th>PDF</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div id="empty" class="empty" hidden>Sin movimientos para este filtro.</div>
</div>
<script>
const fmt = n => n==null ? "" : n.toLocaleString("es-CL");
let TXNS=[], sortK="date", sortDir=1;
fetch("ledger.json").then(r=>r.json()).then(data=>{
const banks=new Set(), flows=new Set(), types=new Set(), accts=new Set();
data.statements.forEach((s,si)=>{
banks.add(s.bank); types.add(s.doc_type); if(s.account_last4) accts.add(s.account_last4);
s.transactions.forEach(t=>{
if(t.flow_type) flows.add(t.flow_type);
TXNS.push({...t, bank:s.bank, doc_type:s.doc_type, account_last4:s.account_last4||"—",
pdf_url:s.pdf_url, period:s.period_start});
});
});
document.getElementById("sub").textContent = "· "+banks.size+" bancos · "+TXNS.length+" movimientos";
const bSel=document.getElementById("bank"), fSel=document.getElementById("flow"),
tSel=document.getElementById("type"), aSel=document.getElementById("acct");
[...banks].sort().forEach(b=>bSel.add(new Option(b,b)));
[...flows].sort().forEach(f=>fSel.add(new Option(flowLabel(f),f)));
[...types].sort().forEach(t=>tSel.add(new Option(label(t),t)));
[...accts].sort().forEach(a=>aSel.add(new Option("…"+a,a)));
render();
});
const label=t=>({cuenta_corriente:"Cta Corriente",cuenta_vista:"Cta Vista",linea_credito:"Línea Crédito",tarjeta_credito:"Tarjeta"}[t]||t);
const FLOW={self_transfer:["Auto-transf.","#8a8a9a"],credit_line:["Línea créd.","#8a8a9a"],card_payment:["Pago tarjeta","#8a8a9a"],fee:["Comisión","#b59a6a"],income:["Ingreso","#5ec48a"],inter_person:["A persona","#c8b5d1"],expense:["Gasto","#d98a8a"],other:["Otro","#8a8a9a"]};
const flowLabel=f=>(FLOW[f]?FLOW[f][0]:f);
const INTERNAL=new Set(["self_transfer","credit_line","card_payment"]);
function current(){
const q=document.getElementById("q").value.toLowerCase().trim();
const d=document.querySelector("#dir .on").dataset.d;
const bk=document.getElementById("bank").value;
const fl=document.getElementById("flow").value;
const ac=document.getElementById("acct").value;
const hideInt=document.getElementById("hideInternal").checked;
let r=TXNS.filter(t=>{
if(d!=="all" && t.direction!==d) return false;
if(bk && t.bank!==bk) return false;
if(fl && t.flow_type!==fl) return false;
if(hideInt && INTERNAL.has(t.flow_type)) return false;
const ty=document.getElementById("type").value;
if(ty && t.doc_type!==ty) return false;
if(ac && t.account_last4!==ac) return false;
if(q && !(t.description||"").toLowerCase().includes(q) && !(t.account_last4||"").includes(q)
&& !(t.counterparty||"").toLowerCase().includes(q)) return false;
return true;
});
r.sort((a,b)=>{
let x=a[sortK], y=b[sortK];
if(sortK==="amount"||sortK==="balance"){x=x??-1e18;y=y??-1e18;}
return (x>y?1:x<y?-1:0)*sortDir;
});
return r;
}
function render(){
const r=current(), tb=document.getElementById("rows");
document.getElementById("empty").hidden = r.length>0;
tb.innerHTML = r.map(t=>{
const cls=t.direction==="credit"?"in":"out", arr=t.direction==="credit"?"▲":"▼";
const plat=/MERCADOPAGO|MERPAGO|PAYSCAN|SUMUP|TUU/i.test(t.description||"")?'<span class="platform">plataforma</span>':"";
const cp = t.counterparty ? `<span class="platform">→ ${esc(t.counterparty)}</span>` : "";
const fdef = FLOW[t.flow_type]||["",""];
const fchip = t.flow_type ? `<span class="tag" style="color:${fdef[1]}">${fdef[0]}</span>` : "";
const rowcls = INTERNAL.has(t.flow_type) ? ' style="opacity:.6"' : "";
return `<tr${rowcls}>
<td>${t.date||""}</td>
<td>${esc(t.bank||"")}</td>
<td>${fchip}</td>
<td>…${t.account_last4}</td>
<td class="dir ${cls}">${arr}</td>
<td class="amt ${cls}">${fmt(t.amount)}</td>
<td class="bal">${fmt(t.balance)}</td>
<td class="desc">${esc(t.description||"")}${plat}${cp}</td>
<td><a class="pdf" href="${t.pdf_url}" target="_blank">📄 abrir</a></td>
</tr>`;}).join("");
let cr=0,db=0,intern=0;
for(const t of r){
if(INTERNAL.has(t.flow_type)){ intern+=t.amount; }
else if(t.direction==="credit") cr+=t.amount;
else db+=t.amount;
}
document.getElementById("s-count").textContent=r.length;
document.getElementById("s-in").textContent=fmt(cr);
document.getElementById("s-out").textContent=fmt(db);
document.getElementById("s-int").textContent=fmt(intern);
const net=cr-db; const ne=document.getElementById("s-net");
ne.textContent=(net<0?"-":"")+fmt(Math.abs(net)); ne.className=net<0?"out":"in";
}
const esc=s=>s.replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
document.getElementById("q").addEventListener("input",render);
document.getElementById("bank").addEventListener("change",render);
document.getElementById("flow").addEventListener("change",render);
document.getElementById("type").addEventListener("change",render);
document.getElementById("acct").addEventListener("change",render);
document.getElementById("hideInternal").addEventListener("change",render);
document.querySelectorAll("#dir button").forEach(b=>b.onclick=()=>{
document.querySelectorAll("#dir button").forEach(x=>x.classList.remove("on"));
b.classList.add("on"); render();});
document.querySelectorAll("thead th[data-k]").forEach(th=>th.onclick=()=>{
const k=th.dataset.k; if(sortK===k) sortDir*=-1; else {sortK=k; sortDir=1;} render();});
</script>
</body>
</html>

1
web/ledger.json Normal file

File diff suppressed because one or more lines are too long

305
web/money-trace.html Normal file
View File

@ -0,0 +1,305 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Money Trace</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="loading">reconstructing money flow…</div>
<div id="app" style="display:none;">
<header class="bar">
<div class="brand">
<div class="mark">money<b>·</b>trace</div>
<div class="sub">725 txns · 12 banks · 10 months</div>
</div>
<div class="stats">
<div class="stat income"><div class="lab"><i style="background:var(--c-income)"></i>Real income</div><div class="val num" id="s-in"></div></div>
<div class="stat spend"><div class="lab"><i style="background:var(--c-spend)"></i>Real spending</div><div class="val num" id="s-out"></div></div>
<div class="stat internal"><div class="lab"><i style="background:var(--c-internal)"></i>Internal shuffled</div><div class="val num" id="s-int"></div></div>
<div class="stat net"><div class="lab"><i style="background:var(--ink)"></i>Net real</div><div class="val num" id="s-net"></div></div>
<a href="dashboard.html" style="text-decoration:none; align-self:center; margin-left:8px;">
<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;"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
Dashboard
</button></div>
</a>
</div>
</header>
<div class="controls">
<div class="seg" id="viewSeg">
<button data-v="sankey" class="on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h6c4 0 4 7 9 7h3M3 12h4c5 0 4 7 11 7h3M3 19h7"/></svg>
Flow
</button>
<button data-v="orbit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><ellipse cx="12" cy="12" rx="9" ry="4"/><ellipse cx="12" cy="12" rx="4" ry="9"/></svg>
Orbit
</button>
</div>
<div class="toggle" id="internalToggle"><div class="track"></div><div><div class="lbl">Hide internal flows</div></div></div>
<div class="scrub">
<button class="play" id="playBtn" title="Play through months">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<div class="track-wrap">
<div class="months"><span id="mLabel">All months</span><b id="mRange"></b></div>
<div class="bars" id="bars"></div>
</div>
</div>
<div class="dropdown" id="bankDrop">
<button class="btn">Banks <span class="cnt" id="bankCnt">all 12</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="menu" id="bankMenu"></div>
</div>
</div>
<div class="view" id="view">
<svg id="svg"></svg>
<div class="empty" id="empty" style="display:none;">No flows for this filter.</div>
<div class="legend" id="legend" style="position:absolute;left:20px;bottom:14px;">
<div class="item"><i style="background:var(--c-income)"></i>Income</div>
<div class="item"><i style="background:var(--c-spend)"></i>Spending</div>
<div class="item"><i style="background:var(--c-person)"></i>To/from a person</div>
<div class="item"><i style="background:var(--c-fee)"></i>Fees</div>
<div class="item dash"><i></i>Internal cycle</div>
</div>
</div>
</div>
<div id="tip"></div>
<div id="tweaks-root"></div>
<script src="engine.js"></script>
<script src="sankey-view.js"></script>
<script src="orbit-view.js"></script>
<script>
(function () {
'use strict';
const $ = s => document.querySelector(s);
const state = { view: 'sankey', hideInternal: false, months: null, banks: null };
let META = null, GRAPH = null, playTimer = null, playIdx = -1;
// ---------- tooltip ----------
const tip = $('#tip');
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const catC = c => ({ income:'--c-income', inter_in:'--c-person', inter_out:'--c-person', expense:'--c-spend', fee:'--c-fee', internal_card:'--c-internal', internal_line:'--c-internal', internal_self:'--c-internal' })[c] || '--ink-mute';
function place(e) {
const pad = 16, w = tip.offsetWidth, h = tip.offsetHeight;
let x = e.clientX + pad, y = e.clientY + pad;
if (x + w > innerWidth - 8) x = e.clientX - w - pad;
if (y + h > innerHeight - 8) y = e.clientY - h - pad;
tip.style.left = x + 'px'; tip.style.top = y + 'px';
}
function rows(txns) {
const top = txns.slice().sort((a,b)=>b.amount-a.amount).slice(0,6);
let html = '<div class="t-rows">';
for (const t of top) html += `<div class="t-row"><span class="d">${t.date.slice(5).replace('-','/')}</span><span class="desc">${esc(window.MT.cleanDesc(t.description) || t.counterparty || '—')}</span><span class="a">${window.MT.CLP(t.amount)}</span></div>`;
html += '</div>';
if (txns.length > 6) html += `<div class="t-more">+${txns.length - 6} more transactions</div>`;
return html;
}
const esc = s => String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
window.MTtip = {
node(e, n) {
const ls = [...(n._out||[]), ...(n._in||[]), ...(n._selfOut||[]), ...(n._selfIn||[])];
const all = ls.length ? ls : GRAPH.links.filter(l => l._sn === n || l._tn === n);
const txns = []; all.forEach(l => l.txns.forEach(t => txns.push(t)));
const col = cssv('--ink');
tip.innerHTML = `<div class="t-head"><span class="t-dot" style="background:${col}"></span><span class="t-title">${esc(n.label)}</span></div>
<div class="t-amt num">${window.MT.CLP(n.value)}</div>
<div class="t-meta">${n.sub} · in ${window.MT.CLPk(n.inSum||0)} · out ${window.MT.CLPk(n.outSum||0)} · ${txns.length} txns</div>${rows(txns)}`;
tip.classList.add('show'); place(e);
},
link(e, l) {
const col = cssv(catC(l.cat));
tip.innerHTML = `<div class="t-head"><span class="t-dot" style="background:${col}"></span><span class="t-title">${esc(l._sn.label)} → ${esc(l._tn.label)}</span></div>
<div class="t-amt num" style="color:${col}">${window.MT.CLP(l.value)}</div>
<div class="t-meta">${window.MT.CAT[l.cat].label} · ${l.txns.length} txns</div>${rows(l.txns)}`;
tip.classList.add('show'); place(e);
},
hide() { tip.classList.remove('show'); }
};
// ---------- render ----------
function rebuild() {
GRAPH = window.MT.build(state);
// stats
$('#s-in').innerHTML = fmtBig(GRAPH.totals.realIn);
$('#s-out').innerHTML = fmtBig(GRAPH.totals.realOut);
$('#s-int').innerHTML = fmtBig(GRAPH.totals.internal);
const net = GRAPH.totals.net;
$('#s-net').innerHTML = (net < 0 ? '' : '') + fmtBig(Math.abs(net));
render();
}
function fmtBig(v) {
if (v >= 1e6) return '$' + (v/1e6).toLocaleString('es-CL',{minimumFractionDigits:1,maximumFractionDigits:1}) + '<small>M</small>';
if (v >= 1e3) return '$' + Math.round(v/1e3).toLocaleString('es-CL') + '<small>k</small>';
return window.MT.CLP(v);
}
function render() {
if (!GRAPH) return;
const view = $('#view'), svg = $('#svg');
const w = view.clientWidth, h = view.clientHeight;
const ok = (state.view === 'sankey' ? window.SankeyView : window.OrbitView).render(svg, GRAPH, { width: w, height: h });
$('#empty').style.display = ok ? 'none' : 'grid';
}
// ---------- controls ----------
$('#viewSeg').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
state.view = b.dataset.v;
[...$('#viewSeg').children].forEach(x => x.classList.toggle('on', x === b));
render();
});
const itog = $('#internalToggle');
itog.addEventListener('click', () => { state.hideInternal = !state.hideInternal; itog.classList.toggle('on', state.hideInternal); rebuild(); });
// month bars
function buildBars() {
const counts = {}; window.MT.tx.forEach(t => counts[t.ym] = (counts[t.ym]||0)+1);
const months = window.MT.months;
const max = Math.max(...months.map(m => counts[m]||0));
const bars = $('#bars'); bars.innerHTML = '';
months.forEach(m => {
const b = document.createElement('div');
b.className = 'b'; b.style.height = (8 + (counts[m]||0)/max*22) + 'px';
b.title = window.MT.MONTH_LABEL(m) + ' · ' + (counts[m]||0) + ' txns';
b.dataset.m = m;
b.addEventListener('click', () => selectMonth(m));
bars.appendChild(b);
});
$('#mRange').textContent = window.MT.MONTH_LABEL(months[0]) + ' ' + window.MT.MONTH_LABEL(months[months.length-1]);
}
function selectMonth(m) {
stopPlay();
if (state.months && state.months.length === 1 && state.months[0] === m) { state.months = null; }
else state.months = [m];
syncBars(); rebuild();
}
function syncBars() {
const sel = state.months;
[...$('#bars').children].forEach(b => b.classList.toggle('active', sel ? sel.includes(b.dataset.m) : false));
$('#mLabel').textContent = sel && sel.length === 1 ? window.MT.MONTH_LABEL(sel[0]) : 'All months';
}
$('#playBtn').addEventListener('click', () => { playTimer ? stopPlay() : startPlay(); });
function startPlay() {
const months = window.MT.months;
playIdx = state.months && state.months.length === 1 ? months.indexOf(state.months[0]) : -1;
$('#playBtn').innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>';
playTimer = setInterval(() => {
playIdx++;
if (playIdx >= months.length) { stopPlay(); state.months = null; syncBars(); rebuild(); return; }
state.months = [months[playIdx]]; syncBars(); rebuild();
}, 1100);
}
function stopPlay() {
if (playTimer) clearInterval(playTimer); playTimer = null;
$('#playBtn').innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
}
// bank dropdown
function buildBankMenu() {
const counts = {}; window.MT.tx.forEach(t => counts[t.bank] = (counts[t.bank]||0)+1);
const menu = $('#bankMenu'); menu.innerHTML = '';
const all = document.createElement('div'); all.className = 'all'; all.textContent = 'Select all';
all.addEventListener('click', () => { state.banks = null; refreshBankMenu(); rebuild(); });
menu.appendChild(all);
menu.appendChild(Object.assign(document.createElement('div'), { className: 'sep' }));
window.MT.banks.forEach(bk => {
const row = document.createElement('div'); row.className = 'row'; row.dataset.b = bk;
row.innerHTML = `<span class="ck"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12l5 5 9-11"/></svg></span><span>${bk}</span><span class="n">${counts[bk]||0}</span>`;
row.addEventListener('click', () => {
const cur = state.banks || window.MT.banks.slice();
const i = cur.indexOf(bk);
if (i >= 0) cur.splice(i, 1); else cur.push(bk);
state.banks = cur.length === window.MT.banks.length ? null : cur.slice();
refreshBankMenu(); rebuild();
});
menu.appendChild(row);
});
refreshBankMenu();
}
function refreshBankMenu() {
const sel = state.banks;
[...$('#bankMenu').querySelectorAll('.row')].forEach(r => r.classList.toggle('on', sel ? sel.includes(r.dataset.b) : true));
$('#bankCnt').textContent = sel ? sel.length + ' / 12' : 'all 12';
}
$('#bankDrop > .btn').addEventListener('click', e => { e.stopPropagation(); $('#bankDrop').classList.toggle('open'); });
document.addEventListener('click', () => $('#bankDrop').classList.remove('open'));
$('#bankMenu').addEventListener('click', e => e.stopPropagation());
// resize
let rt; new ResizeObserver(() => { clearTimeout(rt); rt = setTimeout(render, 80); }).observe($('#view'));
window.MTapply = render; // re-render hook for Tweaks
// ---------- boot ----------
window.MT.load().then(meta => {
META = meta;
document.querySelector('.brand .sub').textContent = `${window.MT.tx.length} txns · ${window.MT.banks.length} banks · ${window.MT.months.length} months`;
buildBars(); syncBars(); buildBankMenu();
rebuild();
$('#loading').style.display = 'none';
$('#app').style.display = 'flex';
setTimeout(render, 60);
}).catch(err => { $('#loading').textContent = 'failed to load ledger.json — ' + err.message; });
})();
</script>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel">
const TWEAK_DEFAULTS = {
palette: ['#2ee6a6', '#ff5f73', '#b98cff', '#ffc24b'],
glow: 1,
linkOpacity: 0.62,
bg: '#090b0f'
};
function applyTweaks(t) {
const r = document.documentElement.style;
r.setProperty('--c-income', t.palette[0]);
r.setProperty('--c-spend', t.palette[1]);
r.setProperty('--c-person', t.palette[2]);
r.setProperty('--c-fee', t.palette[3]);
r.setProperty('--glow', t.glow);
r.setProperty('--link-op', t.linkOpacity);
r.setProperty('--bg', t.bg);
if (window.MTapply) window.MTapply();
}
function TweakApp() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
React.useEffect(() => { applyTweaks(t); }, [t]);
return (
<TweaksPanel title="Tweaks">
<TweakSection label="Flow palette" hint="income · spending · people · fees" />
<TweakColor label="Colors" value={t.palette} onChange={v => setTweak('palette', v)}
options={[
['#2ee6a6', '#ff5f73', '#b98cff', '#ffc24b'],
['#34d399', '#fb7185', '#a78bfa', '#fbbf24'],
['#00e5a0', '#ff4d6d', '#8c9eff', '#ffd45a'],
['#7CFFB2', '#FF8FA3', '#C9A6FF', '#FFE08A']
]} />
<TweakSection label="Atmosphere" />
<TweakSlider label="Glow" value={t.glow} min={0} max={2} step={0.1} onChange={v => setTweak('glow', v)} />
<TweakSlider label="Flow opacity" value={t.linkOpacity} min={0.3} max={1} step={0.05} onChange={v => setTweak('linkOpacity', v)} />
<TweakColor label="Background" value={t.bg} onChange={v => setTweak('bg', v)}
options={['#090b0f', '#0c0a0e', '#0a0d0c', '#0d0d0d']} />
</TweaksPanel>
);
}
ReactDOM.createRoot(document.getElementById('tweaks-root')).render(<TweakApp />);
</script>
</body>
</html>

156
web/orbit-view.js Normal file
View File

@ -0,0 +1,156 @@
/* money-trace · Orbit (radial) renderer
My accounts + cards sit on an inner ring. Income sources fan in from the
left outer arc; destinations fan out to the right outer arc. Internal
flows (card payments, line sweeps, self-transfers) bow through the centre,
so the "cycles" show up as a grey rosette you can toggle off. */
(function () {
'use strict';
const SVGNS = 'http://www.w3.org/2000/svg';
const el = (n, a) => { const e = document.createElementNS(SVGNS, n); for (const k in (a || {})) e.setAttribute(k, a[k]); return e; };
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const catColor = c => ({
income: '--c-income', inter_in: '--c-person', inter_out: '--c-person',
expense: '--c-spend', fee: '--c-fee',
internal_card: '--c-internal', internal_line: '--c-internal', internal_self: '--c-internal'
})[c] || '--ink-mute';
function render(svg, graph, opts) {
const W = opts.width, H = opts.height;
while (svg.firstChild) svg.removeChild(svg.firstChild);
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
const nodes = graph.nodes, links = graph.links;
if (!nodes.length) return false;
const cx = W / 2, cy = H / 2;
const R1 = Math.min(W, H) * 0.215; // inner ring (my accounts + cards)
const R2 = Math.min(W, H) * 0.405; // outer ring (sources + destinations)
const byId = new Map(nodes.map(n => [n.id, n]));
const maxVal = Math.max(...nodes.map(n => n.value));
const maxLink = Math.max(...links.map(l => l.value));
const rOf = v => 4 + Math.sqrt(v / maxVal) * 22;
const wOf = v => Math.max(0.7, (v / maxLink) * 15);
// classify nodes into rings
const inner = nodes.filter(n => n.col === 1 || n.col === 2); // accounts + cards
const sources = nodes.filter(n => n.col === 0);
const dests = nodes.filter(n => n.col === 3);
inner.sort((a, b) => (a.col - b.col) || (b.value - a.value));
sources.sort((a, b) => b.value - a.value);
dests.sort((a, b) => b.value - a.value);
// place inner ring: accounts across the upper arc, cards across the lower arc
const place = (arr, a0, a1, R) => {
const n = arr.length;
arr.forEach((node, i) => {
const t = n === 1 ? 0.5 : i / (n - 1);
const ang = a0 + (a1 - a0) * t;
node._a = ang; node._x = cx + Math.cos(ang) * R; node._y = cy + Math.sin(ang) * R; node._R = R;
});
};
const accts = inner.filter(n => n.col === 1), cards = inner.filter(n => n.col === 2);
const D = Math.PI / 180;
// accounts: top arc; cards: bottom arc
place(accts, -155 * D, -25 * D, R1);
place(cards, 25 * D, 155 * D, R1);
// sources: left outer arc; dests: right outer arc
place(sources, 130 * D, 230 * D, R2);
place(dests, -50 * D, 50 * D, R2);
const defs = el('defs'); svg.appendChild(defs);
const linkG = el('g'); svg.appendChild(linkG);
const nodeG = el('g'); svg.appendChild(nodeG);
// rings (guides)
[R1, R2].forEach(R => svg.insertBefore(el('circle', { cx, cy, r: R, class: 'orbit-ring' }), linkG));
function path(l) {
const s = l._sn, t = l._tn;
const internal = l.kind === 'internal';
// control point: internal bows through centre; real arcs gently toward centre
let ctrl;
if (internal) {
const mx = (s._x + t._x) / 2, my = (s._y + t._y) / 2;
ctrl = [cx + (mx - cx) * 0.15, cy + (my - cy) * 0.15];
} else {
const mx = (s._x + t._x) / 2, my = (s._y + t._y) / 2;
ctrl = [cx + (mx - cx) * 0.55, cy + (my - cy) * 0.55];
}
return `M${s._x},${s._y} Q${ctrl[0]},${ctrl[1]} ${t._x},${t._y}`;
}
const linkEls = [];
const ordered = links.slice().sort((a, b) => (a.kind === 'internal' ? -1 : 1) - (b.kind === 'internal' ? -1 : 1));
for (const l of ordered) {
const s = byId.get(l.source), t = byId.get(l.target);
if (!s || !t) continue; l._sn = s; l._tn = t;
const p = el('path', {
d: path(l),
class: 'link ' + (l.kind === 'real' ? 'real glow-real ' : 'internal ') + l.cat,
'stroke-width': wOf(l.value)
});
p.__link = l; linkG.appendChild(p); linkEls.push(p);
}
// centre hub
const hub = el('g');
hub.appendChild(el('circle', { cx, cy, r: 46, fill: cssv('--panel-2'), stroke: cssv('--line-2'), 'stroke-width': 1 }));
const t1 = el('text', { x: cx, y: cy - 6, class: 'orbit-center-lab', 'text-anchor': 'middle' }); t1.textContent = 'MY MONEY';
const t2 = el('text', { x: cx, y: cy + 13, class: 'orbit-sub', 'text-anchor': 'middle' });
t2.textContent = 'net ' + window.MT.CLPk(graph.totals.net);
hub.appendChild(t1); hub.appendChild(t2); nodeG.appendChild(hub);
// nodes
const nodeEls = [];
function dom(n) {
const ls = links.filter(l => l._sn === n || l._tn === n);
const m = {}; ls.forEach(l => m[l.cat] = (m[l.cat] || 0) + l.value);
let b = null, bv = -1; for (const k in m) if (m[k] > bv) { bv = m[k]; b = k; } return b;
}
for (const n of nodes) {
const g = el('g'); g.__node = n;
const r = rOf(n.value);
const col = cssv(catColor(dom(n))) || cssv('--ink-mute');
const isInner = n.col === 1 || n.col === 2;
const c = el('circle', { cx: n._x, cy: n._y, r, fill: isInner ? cssv('--panel-2') : col, stroke: col, 'stroke-width': isInner ? 1.6 : 0 });
c.style.opacity = 0.95;
g.appendChild(c);
// label: outer nodes -> radially outward; inner -> outward small
const outward = Math.atan2(n._y - cy, n._x - cx);
const lx = n._x + Math.cos(outward) * (r + 8);
const ly = n._y + Math.sin(outward) * (r + 8);
const anchor = Math.cos(outward) < -0.2 ? 'end' : (Math.cos(outward) > 0.2 ? 'start' : 'middle');
const lab = el('text', { x: lx, y: ly, class: 'orbit-node-label', 'text-anchor': anchor, 'dominant-baseline': 'middle' });
lab.textContent = n.label.length > 18 ? n.label.slice(0, 17) + '…' : n.label;
g.appendChild(lab);
if (isInner && n.sub) {
const sub = el('text', { x: lx, y: ly + 12, class: 'orbit-sub', 'text-anchor': anchor, 'dominant-baseline': 'middle' });
sub.textContent = n.sub; g.appendChild(sub);
}
nodeG.appendChild(g); nodeEls.push(g);
}
// interactions
function focus(ls, ns) {
linkEls.forEach(p => p.classList.toggle('dim', ls && !ls.has(p.__link)));
nodeEls.forEach(g => g.classList.toggle('dim', ns && !ns.has(g.__node)));
}
const clear = () => { linkEls.forEach(p => p.classList.remove('dim')); nodeEls.forEach(g => g.classList.remove('dim')); };
linkEls.forEach(p => {
p.addEventListener('mousemove', e => window.MTtip.link(e, p.__link));
p.addEventListener('mouseenter', () => focus(new Set([p.__link]), new Set([p.__link._sn, p.__link._tn])));
p.addEventListener('mouseleave', () => { window.MTtip.hide(); clear(); });
});
nodeEls.forEach(g => {
const n = g.__node;
const ls = new Set(links.filter(l => l._sn === n || l._tn === n));
const ns = new Set([n]); ls.forEach(l => { ns.add(l._sn); ns.add(l._tn); });
g.addEventListener('mousemove', e => window.MTtip.node(e, n));
g.addEventListener('mouseenter', () => focus(ls, ns));
g.addEventListener('mouseleave', () => { window.MTtip.hide(); clear(); });
});
return true;
}
window.OrbitView = { render };
})();

225
web/sankey-view.js Normal file
View File

@ -0,0 +1,225 @@
/* money-trace · layered Sankey renderer (SVG, vanilla)
Columns: 0 sources · 1 accounts · 2 cards/lines · 3 destinations.
Real flows pop (solid, glow); internal flows recede (dashed, grey).
Self-transfers (col1col1) render as right-bulging recirculation loops. */
(function () {
'use strict';
const SVGNS = 'http://www.w3.org/2000/svg';
const el = (n, a) => { const e = document.createElementNS(SVGNS, n); for (const k in (a || {})) e.setAttribute(k, a[k]); return e; };
const COL_HEAD = ['Income sources', 'My accounts', 'Cards & lines', 'Where it goes'];
function dominantCat(links) {
const m = {};
for (const l of links) m[l.cat] = (m[l.cat] || 0) + l.value;
let best = null, bv = -1; for (const k in m) if (m[k] > bv) { bv = m[k]; best = k; }
return best;
}
const catColor = c => ({
income: '--c-income', inter_in: '--c-person', inter_out: '--c-person',
expense: '--c-spend', fee: '--c-fee',
internal_card: '--c-internal', internal_line: '--c-internal', internal_self: '--c-internal'
})[c] || '--ink-mute';
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
function render(svg, graph, opts) {
const W = opts.width, H = opts.height;
while (svg.firstChild) svg.removeChild(svg.firstChild);
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
const nodes = graph.nodes, links = graph.links;
if (!nodes.length) { return false; }
// layout constants
const padTop = 40, padBot = 26;
const nodeW = 15, nodePad = 12;
const mLeft = 168, mRight = 172;
const availH = H - padTop - padBot;
const innerW = W - mLeft - mRight;
const colX = [mLeft, mLeft + innerW * 0.34, mLeft + innerW * 0.67, mLeft + innerW];
// group + sort nodes by column
const cols = [[], [], [], []];
for (const n of nodes) cols[n.col].push(n);
const orderKind = { source: 0, person: 0, account: 0, card: 0, spend: 0, fee: 1 };
for (const c of cols) c.sort((a, b) => b.value - a.value);
// shared value→px scale (binding column fills availH)
let scale = Infinity;
cols.forEach(c => {
if (!c.length) return;
const sum = c.reduce((s, n) => s + n.value, 0);
const s = (availH - (c.length - 1) * nodePad) / sum;
if (s < scale) scale = s;
});
if (!isFinite(scale)) scale = 1;
// position nodes
cols.forEach((c, ci) => {
const total = c.reduce((s, n) => s + n.value * scale, 0) + (c.length - 1) * nodePad;
let y = padTop + (availH - total) / 2;
for (const n of c) {
n._x = colX[ci]; n._w = nodeW; n._y = y; n._h = Math.max(2, n.value * scale);
n._rcur = n._y; n._lcur = n._y; // edge cursors
y += n._h + nodePad;
}
});
// split links
const fwd = [], self = [];
for (const l of links) {
l._s = graph.nodes.find ? null : null;
}
const byId = new Map(nodes.map(n => [n.id, n]));
for (const l of links) {
const s = byId.get(l.source), t = byId.get(l.target);
if (!s || !t) continue;
l._sn = s; l._tn = t;
if (s.col === t.col) self.push(l); else fwd.push(l);
}
// assign forward endpoints: source right edge, target left edge
// order each node's links by opposite node center to reduce crossings
const sortKey = (l, side) => (side === 'src' ? l._tn._y : l._sn._y);
for (const n of nodes) {
n._out = fwd.filter(l => l._sn === n).sort((a, b) => sortKey(a, 'src') - sortKey(b, 'src'));
n._in = fwd.filter(l => l._tn === n).sort((a, b) => sortKey(a, 'tgt') - sortKey(b, 'tgt'));
}
for (const n of nodes) {
for (const l of n._out) { const w = Math.max(1, l.value * scale); l._sx = n._x + n._w; l._sy = n._rcur + w / 2; n._rcur += w; l._w = w; }
for (const l of n._in) { const w = Math.max(1, l.value * scale); l._tx = n._x; l._ty = n._lcur + w / 2; n._lcur += w; }
}
// self links: both endpoints on right edge, bulge right
for (const n of nodes) {
n._selfOut = self.filter(l => l._sn === n).sort((a, b) => a._tn._y - b._tn._y);
n._selfIn = self.filter(l => l._tn === n).sort((a, b) => a._sn._y - b._sn._y);
}
for (const n of nodes) {
for (const l of n._selfOut) { const w = Math.max(1, l.value * scale); l._sx = n._x + n._w; l._sy = n._rcur + w / 2; n._rcur += w; l._w = w; }
for (const l of n._selfIn) { const w = Math.max(1, l.value * scale); l._tx = n._x + n._w; l._ty = n._rcur + w / 2; n._rcur += w; }
}
// ---- draw ----
const defs = el('defs');
svg.appendChild(defs);
// label de-collision for outer columns (shift label centres, keep node positions)
function spread(colArr, minGap) {
const arr = colArr.slice().sort((a, b) => a._y - b._y);
let prev = -Infinity;
for (const n of arr) { let c = n._y + n._h / 2; if (c < prev + minGap) c = prev + minGap; n._lc = c; prev = c; }
}
spread(cols[0], 30); spread(cols[3], 30);
spread(cols[1], 30); spread(cols[2], 30);
// column headers
cols.forEach((c, ci) => {
if (!c.length) return;
const x = ci === 3 ? colX[ci] + nodeW : colX[ci];
const anchor = ci === 0 ? 'start' : (ci === 3 ? 'end' : 'start');
const tx = ci === 0 ? colX[0] - 0 : (ci === 3 ? colX[3] + nodeW : colX[ci]);
const th = el('text', { x: tx, y: 22, class: 'colhead', 'text-anchor': ci === 3 ? 'start' : 'start' });
th.textContent = COL_HEAD[ci];
svg.appendChild(th);
});
const linkG = el('g'); svg.appendChild(linkG);
const nodeG = el('g'); svg.appendChild(nodeG);
function linkPath(l) {
if (l._sn.col === l._tn.col) {
// self loop, bulge right
const depth = 46 + Math.min(70, l._w * 1.2) + (l._sy % 23);
const x = l._sx;
return `M${x},${l._sy} C${x + depth},${l._sy} ${x + depth},${l._ty} ${x},${l._ty}`;
}
const sx = l._sx, sy = l._sy, tx = l._tx, ty = l._ty;
const mx = (sx + tx) / 2;
return `M${sx},${sy} C${mx},${sy} ${mx},${ty} ${tx},${ty}`;
}
const linkEls = [];
// draw internal first (under), then real (over)
const ordered = links.slice().sort((a, b) => (a.kind === 'internal' ? -1 : 1) - (b.kind === 'internal' ? -1 : 1));
for (const l of ordered) {
if (l._sx == null || l._tx == null) continue;
const p = el('path', {
d: linkPath(l),
class: 'link ' + (l.kind === 'real' ? 'real glow-real ' : 'internal ') + l.cat,
'stroke-width': l._w
});
p.__link = l;
linkG.appendChild(p);
linkEls.push(p);
}
// nodes
const nodeEls = [];
for (const n of nodes) {
const g = el('g'); g.__node = n;
const dom = dominantCat([...n._out, ...n._in, ...n._selfOut, ...n._selfIn]);
const col = cssv(catColor(dom)) || cssv('--ink-mute');
const r = el('rect', { x: n._x, y: n._y, width: n._w, height: n._h, rx: 3, fill: col, class: 'node-rect' });
r.style.opacity = n.kind === 'account' || n.kind === 'card' ? 0.92 : 0.85;
g.appendChild(r);
// labels
const cy = n._lc != null ? n._lc : n._y + n._h / 2;
if (n.col === 0) {
const t = el('text', { x: n._x - 9, y: cy - 1, class: 'node-label', 'text-anchor': 'end', 'dominant-baseline': 'middle' });
t.textContent = n.label;
const v = el('text', { x: n._x - 9, y: cy + 13, class: 'node-val', 'text-anchor': 'end', 'dominant-baseline': 'middle' });
v.textContent = MTfmt(n.value);
g.appendChild(t); g.appendChild(v);
} else if (n.col === 3) {
const t = el('text', { x: n._x + n._w + 9, y: cy - 1, class: 'node-label', 'dominant-baseline': 'middle' });
t.textContent = n.label;
const v = el('text', { x: n._x + n._w + 9, y: cy + 13, class: 'node-val', 'dominant-baseline': 'middle' });
v.textContent = MTfmt(n.value);
g.appendChild(t); g.appendChild(v);
} else {
// middle: label above node
const t = el('text', { x: n._x + n._w + 8, y: cy - 5, class: 'node-label', 'dominant-baseline': 'middle' });
t.textContent = n.label;
const s = el('text', { x: n._x + n._w + 8, y: cy + 9, class: 'node-sub', 'dominant-baseline': 'middle' });
s.textContent = n.sub + ' ' + MTfmt(n.value);
g.appendChild(t); g.appendChild(s);
}
nodeG.appendChild(g);
nodeEls.push(g);
}
// ---- interactions ----
function setFocus(activeLinks, activeNodes) {
linkEls.forEach(p => {
if (!activeLinks || activeLinks.has(p.__link)) p.classList.remove('dim');
else p.classList.add('dim');
});
nodeEls.forEach(g => {
if (!activeNodes || activeNodes.has(g.__node)) g.classList.remove('dim');
else g.classList.add('dim');
});
}
function clearFocus() { linkEls.forEach(p => p.classList.remove('dim')); nodeEls.forEach(g => g.classList.remove('dim')); }
linkEls.forEach(p => {
p.addEventListener('mousemove', e => { window.MTtip.link(e, p.__link); });
p.addEventListener('mouseenter', () => { const s = new Set([p.__link]); setFocus(s, new Set([p.__link._sn, p.__link._tn])); });
p.addEventListener('mouseleave', () => { window.MTtip.hide(); clearFocus(); });
});
nodeEls.forEach(g => {
const n = g.__node;
const ls = new Set([...n._out, ...n._in, ...n._selfOut, ...n._selfIn]);
const ns = new Set([n]); ls.forEach(l => { ns.add(l._sn); ns.add(l._tn); });
g.addEventListener('mousemove', e => window.MTtip.node(e, n));
g.addEventListener('mouseenter', () => setFocus(ls, ns));
g.addEventListener('mouseleave', () => { window.MTtip.hide(); clearFocus(); });
});
return true;
}
function MTfmt(v) { return window.MT.CLPk(v); }
window.SankeyView = { render };
})();

193
web/styles.css Normal file
View File

@ -0,0 +1,193 @@
/* money-trace · dark finance-terminal aesthetic */
:root {
--bg: #090b0f;
--bg-grid: rgba(255,255,255,0.022);
--panel: #10141b;
--panel-2: #161b24;
--line: rgba(255,255,255,0.075);
--line-2: rgba(255,255,255,0.13);
--ink: #eaeff7;
--ink-mute: #8b95a6;
--ink-dim: #545d6d;
--c-income: #2ee6a6; /* real money in */
--c-spend: #ff5f73; /* real money out */
--c-person: #b98cff; /* inter-person */
--c-fee: #ffc24b; /* fees & interest*/
--c-internal:#4b5466; /* cycles / noise */
--glow: 1; /* 0..1, multiplied into drop-shadows */
--link-op: 0.62; /* base opacity of real links */
--font-ui: 'Space Grotesk', system-ui, sans-serif;
--font-num: 'IBM Plex Mono', ui-monospace, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background:
radial-gradient(1200px 700px at 78% -8%, rgba(46,230,166,0.05), transparent 60%),
radial-gradient(900px 600px at 6% 110%, rgba(185,140,255,0.045), transparent 55%),
linear-gradient(0deg, var(--bg-grid) 1px, transparent 1px) 0 0 / 100% 34px,
var(--bg);
color: var(--ink);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
.num { font-family: var(--font-num); font-feature-settings: 'tnum' 1; letter-spacing: -0.01em; }
/* ---- app frame ---- */
#app { display: flex; flex-direction: column; height: 100vh; }
/* ---- header / summary bar ---- */
header.bar {
display: flex; align-items: stretch; gap: 0;
padding: 14px 22px 13px; border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255,255,255,0.018), transparent);
}
.brand { display: flex; flex-direction: column; justify-content: center; min-width: 168px; }
.brand .mark {
font-weight: 600; font-size: 15px; letter-spacing: 0.26em;
text-transform: uppercase; color: var(--ink);
}
.brand .mark b { color: var(--c-income); font-weight: 600; }
.brand .sub { font-size: 11px; color: var(--ink-dim); letter-spacing: 0.04em; margin-top: 3px; }
.stats { display: flex; gap: 30px; margin-left: auto; align-items: center; }
.stat { display: flex; flex-direction: column; gap: 4px; min-width: 132px; }
.stat .lab {
font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--ink-mute); display: flex; align-items: center; gap: 6px;
}
.stat .lab i { width: 7px; height: 7px; border-radius: 2px; display: inline-block; }
.stat .val { font-size: 27px; font-weight: 500; line-height: 1; }
.stat .val small { font-size: 13px; color: var(--ink-dim); margin-left: 2px; }
.stat.income .val { color: var(--c-income); }
.stat.spend .val { color: var(--c-spend); }
.stat.internal .val { color: var(--ink-mute); }
.stat.net .val { color: var(--ink); }
.stat .delta { font-size: 11px; color: var(--ink-dim); }
/* ---- controls strip ---- */
.controls {
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
padding: 11px 22px; border-bottom: 1px solid var(--line);
background: var(--panel);
}
.seg { display: inline-flex; background: var(--bg); border: 1px solid var(--line); border-radius: 9px; padding: 3px; gap: 2px; }
.seg button {
font-family: var(--font-ui); font-size: 12.5px; color: var(--ink-mute);
background: transparent; border: 0; padding: 7px 15px; border-radius: 6px;
cursor: pointer; display: flex; align-items: center; gap: 7px; transition: .16s;
}
.seg button:hover { color: var(--ink); }
.seg button.on { background: var(--panel-2); color: var(--ink); box-shadow: inset 0 0 0 1px var(--line-2); }
.seg button svg { width: 14px; height: 14px; }
.toggle { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; user-select: none; }
.toggle .track { width: 38px; height: 21px; border-radius: 20px; background: var(--bg); border: 1px solid var(--line-2); position: relative; transition: .18s; }
.toggle .track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 15px; height: 15px; border-radius: 50%; background: var(--ink-mute); transition: .18s; }
.toggle.on .track { background: rgba(46,230,166,0.22); border-color: var(--c-income); }
.toggle.on .track::after { left: 19px; background: var(--c-income); }
.toggle .lbl { font-size: 12.5px; color: var(--ink); }
.toggle .hint { font-size: 11px; color: var(--ink-dim); }
.ctl-label { font-size: 10.5px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-dim); margin-right: -8px; }
/* month scrubber */
.scrub { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 280px; max-width: 560px; }
.scrub .play { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); cursor: pointer; display: grid; place-items: center; }
.scrub .play:hover { border-color: var(--line-2); }
.scrub .track-wrap { flex: 1; }
.scrub .months { display: flex; justify-content: space-between; font-size: 10px; color: var(--ink-dim); margin-bottom: 5px; }
.scrub .months b { color: var(--c-income); font-weight: 500; font-family: var(--font-num); }
.bars { display: flex; gap: 2px; align-items: flex-end; height: 30px; }
.bars .b { flex: 1; background: var(--line); border-radius: 2px 2px 0 0; cursor: pointer; transition: .14s; min-height: 2px; }
.bars .b:hover { background: var(--ink-dim); }
.bars .b.active { background: linear-gradient(180deg, var(--c-income), rgba(46,230,166,0.35)); }
/* bank filter */
.dropdown { position: relative; }
.dropdown > .btn {
font-family: var(--font-ui); font-size: 12.5px; color: var(--ink); background: var(--bg);
border: 1px solid var(--line); border-radius: 8px; padding: 8px 13px; cursor: pointer;
display: flex; align-items: center; gap: 8px;
}
.dropdown > .btn .cnt { color: var(--ink-dim); font-family: var(--font-num); font-size: 11px; }
.dropdown .menu {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 40;
background: var(--panel-2); border: 1px solid var(--line-2); border-radius: 10px;
padding: 8px; width: 220px; box-shadow: 0 18px 50px rgba(0,0,0,0.55); display: none;
max-height: 360px; overflow: auto;
}
.dropdown.open .menu { display: block; }
.dropdown .menu .row { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border-radius: 7px; cursor: pointer; font-size: 12.5px; }
.dropdown .menu .row:hover { background: var(--panel); }
.dropdown .menu .row .ck { width: 15px; height: 15px; border-radius: 4px; border: 1px solid var(--line-2); display: grid; place-items: center; color: var(--bg); }
.dropdown .menu .row.on .ck { background: var(--c-income); border-color: var(--c-income); }
.dropdown .menu .row .n { color: var(--ink-dim); margin-left: auto; font-family: var(--font-num); font-size: 11px; }
.dropdown .menu .sep { height: 1px; background: var(--line); margin: 6px 4px; }
.dropdown .menu .all { font-size: 11px; color: var(--c-income); padding: 6px 9px; cursor: pointer; }
/* ---- legend ---- */
.legend { display: flex; align-items: center; gap: 16px; margin-left: auto; }
.legend .item { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-mute); }
.legend .item i { width: 16px; height: 3px; border-radius: 2px; display: inline-block; }
.legend .item.dash i { height: 0; border-top: 2px dashed var(--c-internal); width: 18px; }
/* ---- view area ---- */
.view { position: relative; flex: 1; overflow: hidden; }
.view svg { width: 100%; height: 100%; display: block; }
/* sankey */
.node-rect { transition: opacity .18s; }
.node-label { fill: var(--ink); font-size: 12.5px; font-family: var(--font-ui); }
.node-sub { fill: var(--ink-dim); font-size: 10px; font-family: var(--font-num); }
.node-val { fill: var(--ink-mute); font-size: 10.5px; font-family: var(--font-num); }
.colhead { fill: var(--ink-dim); font-size: 10.5px; letter-spacing: 0.16em; text-transform: uppercase; font-family: var(--font-ui); }
.link { fill: none; transition: opacity .18s, stroke-opacity .18s; }
.link.real { opacity: var(--link-op); }
.link.internal { opacity: 0.4; }
.link.income { stroke: var(--c-income); }
.link.inter_in, .link.inter_out { stroke: var(--c-person); }
.link.expense { stroke: var(--c-spend); }
.link.fee { stroke: var(--c-fee); }
.link.internal_card, .link.internal_line, .link.internal_self { stroke: var(--c-internal); stroke-dasharray: 5 5; }
.glow-real { filter: drop-shadow(0 0 calc(5px * var(--glow)) currentColor); }
.dim { opacity: 0.07 !important; }
.hl { opacity: 0.95 !important; }
/* ---- tooltip ---- */
#tip {
position: fixed; z-index: 80; pointer-events: none; max-width: 320px;
background: rgba(13,17,23,0.96); border: 1px solid var(--line-2); border-radius: 11px;
padding: 12px 13px; box-shadow: 0 16px 44px rgba(0,0,0,0.6); backdrop-filter: blur(8px);
opacity: 0; transform: translateY(4px); transition: opacity .12s, transform .12s; font-size: 12.5px;
}
#tip.show { opacity: 1; transform: translateY(0); }
#tip .t-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
#tip .t-dot { width: 9px; height: 9px; border-radius: 3px; }
#tip .t-title { font-weight: 600; font-size: 13px; }
#tip .t-amt { font-family: var(--font-num); font-size: 19px; margin: 2px 0 9px; }
#tip .t-meta { color: var(--ink-mute); font-size: 11px; margin-bottom: 9px; }
#tip .t-rows { display: flex; flex-direction: column; gap: 5px; border-top: 1px solid var(--line); padding-top: 9px; max-height: 200px; overflow: hidden; }
#tip .t-row { display: flex; gap: 10px; font-size: 11.5px; align-items: baseline; }
#tip .t-row .d { color: var(--ink-dim); font-family: var(--font-num); font-size: 10.5px; width: 42px; flex: none; }
#tip .t-row .desc { color: var(--ink-mute); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#tip .t-row .a { font-family: var(--font-num); color: var(--ink); }
#tip .t-more { color: var(--ink-dim); font-size: 10.5px; margin-top: 7px; }
/* orbit view */
.orbit-ring { fill: none; stroke: var(--line); stroke-width: 1; }
.orbit-node-label { fill: var(--ink); font-size: 11.5px; font-family: var(--font-ui); }
.orbit-sub { fill: var(--ink-dim); font-size: 9.5px; font-family: var(--font-num); }
.orbit-center-lab { fill: var(--ink); font-size: 14px; font-family: var(--font-ui); font-weight: 600; }
/* empty state */
.empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--ink-dim); font-size: 14px; }
/* loading */
#loading { position: fixed; inset: 0; display: grid; place-items: center; background: var(--bg); z-index: 200; color: var(--ink-mute); font-family: var(--font-num); font-size: 13px; letter-spacing: 0.1em; }

540
web/tweaks-panel.jsx Normal file
View File

@ -0,0 +1,540 @@
/* BEGIN USAGE */
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// TweakRadio is the segmented control for 23 short options (auto-falls-back to
// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
// options are many or long. For color tweaks always curate 3-4 options rather than
// a free picker; an option can also be a whole 25 color palette (the stored value
// is the array). The Tweak* controls are a floor, not a ceiling build custom
// controls inside the panel if a tweak calls for UI they don't cover.
/* END USAGE */
//
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// useTweaks
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// TweaksPanel
// Floating shell. Registers the protocol listener BEFORE announcing
// availability if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
</div>
</div>
</>
);
}
// Layout helpers
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// Controls
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});