feat: add finance widget and CSS/template updates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
178
finance.js
Normal file
178
finance.js
Normal file
@@ -0,0 +1,178 @@
|
||||
window.initFinanceApp = function() {
|
||||
window.financeApp = {
|
||||
data: null,
|
||||
charts: {},
|
||||
async load() {
|
||||
try {
|
||||
let res = await fetch('/api/finance');
|
||||
let json = await res.json();
|
||||
this.data = json;
|
||||
if (!this.data.monthly || !this.data.monthly.labels) {
|
||||
this.data.monthly = {labels:['Jan'], income:[0], expenses:[0], spend:[0]};
|
||||
}
|
||||
if (!this.data.accounts) this.data.accounts = [];
|
||||
this.renderCharts();
|
||||
this.renderTable();
|
||||
} catch (e) {
|
||||
console.error("Finance App Load Error:", e);
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
await fetch('/api/finance', { method: 'POST', body: JSON.stringify(this.data), headers: {'Content-Type': 'application/json'} });
|
||||
},
|
||||
renderCharts() {
|
||||
const resolveColor = (cssVar, fallback) => {
|
||||
const div = document.createElement('div');
|
||||
div.style.color = fallback;
|
||||
div.style.color = `var(${cssVar})`;
|
||||
div.style.display = 'none';
|
||||
document.body.appendChild(div);
|
||||
const color = getComputedStyle(div).color;
|
||||
document.body.removeChild(div);
|
||||
return color;
|
||||
};
|
||||
|
||||
const cPos = resolveColor('--color-positive', 'hsl(150, 60%, 50%)');
|
||||
const cNeg = resolveColor('--color-negative', 'hsl(0, 70%, 65%)');
|
||||
const cPri = resolveColor('--color-primary', 'hsl(12, 70%, 60%)');
|
||||
const cText = resolveColor('--color-text-subdue', 'rgba(255,255,255,0.6)');
|
||||
|
||||
const resolveGridColor = () => {
|
||||
const div = document.createElement('div');
|
||||
div.style.borderColor = 'rgba(255,255,255,0.05)';
|
||||
div.style.borderColor = 'var(--color-widget-content-border)';
|
||||
div.style.display = 'none';
|
||||
document.body.appendChild(div);
|
||||
const color = getComputedStyle(div).borderColor;
|
||||
document.body.removeChild(div);
|
||||
return color;
|
||||
};
|
||||
const cGrid = resolveGridColor();
|
||||
|
||||
const addAlpha = (rgbStr, alpha) => {
|
||||
let m = rgbStr.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (m) {
|
||||
return `rgba(${m[1]}, ${m[2]}, ${m[3]}, ${alpha})`;
|
||||
}
|
||||
return rgbStr;
|
||||
};
|
||||
|
||||
const cPosAlpha = addAlpha(cPos, 0.6);
|
||||
const cNegAlpha = addAlpha(cNeg, 0.6);
|
||||
const cPriAlpha = addAlpha(cPri, 0.2);
|
||||
|
||||
Chart.defaults.color = cText;
|
||||
Chart.defaults.font.family = 'Outfit, -apple-system, sans-serif';
|
||||
|
||||
const c1 = document.getElementById('incomeExpenseChart');
|
||||
if(c1) {
|
||||
if(this.charts.incomeExpense) this.charts.incomeExpense.destroy();
|
||||
this.charts.incomeExpense = new Chart(c1.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: this.data.monthly.labels,
|
||||
datasets: [
|
||||
{ label: 'Income', data: this.data.monthly.income, backgroundColor: cPosAlpha, borderColor: cPos, borderWidth: 1, borderRadius: 3, barPercentage: 0.6 },
|
||||
{ label: 'Expenses', data: this.data.monthly.expenses, backgroundColor: cNegAlpha, borderColor: cNeg, borderWidth: 1, borderRadius: 3, barPercentage: 0.6 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
layout: { padding: 10 },
|
||||
scales: {
|
||||
y: { display: false, beginAtZero: true },
|
||||
x: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const c2 = document.getElementById('spendChart');
|
||||
if(c2) {
|
||||
if(this.charts.spend) this.charts.spend.destroy();
|
||||
this.charts.spend = new Chart(c2.getContext('2d'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: this.data.monthly.labels,
|
||||
datasets: [{
|
||||
label: 'Spend',
|
||||
data: this.data.monthly.spend,
|
||||
borderColor: cPri,
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
layout: { padding: 10 },
|
||||
scales: {
|
||||
y: { display: false, beginAtZero: true },
|
||||
x: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
renderTable() {
|
||||
const t = document.getElementById('finance-tbody');
|
||||
if(!t) return;
|
||||
t.innerHTML = '';
|
||||
if(!this.data.accounts || this.data.accounts.length === 0) {
|
||||
t.innerHTML = `<tr><td colspan='4' style='text-align:center; padding: 2rem;' class='color-subdue'>No accounts.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
this.data.accounts.forEach((acc, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.borderBottom = '1px solid var(--color-widget-content-border)';
|
||||
const inStyle = `width: 100%; background: transparent; border: none; color: var(--color-text-highlight); padding: 1rem; font-family: inherit; font-size: 1.15rem; outline: none; transition: background 0.2s;`;
|
||||
|
||||
const td1 = document.createElement('td');
|
||||
td1.style.padding = '0';
|
||||
td1.innerHTML = `<input style="${inStyle}" value="${acc.name}" onfocus="this.style.background='var(--color-widget-background-highlight)'" onblur="this.style.background='transparent'" onchange="financeApp.update(${i},'name',this.value)">`;
|
||||
|
||||
const td2 = document.createElement('td');
|
||||
td2.style.padding = '0';
|
||||
let amountCol = acc.balance < 0 ? 'color-negative' : 'color-positive';
|
||||
td2.innerHTML = `<input type="number" step="0.01" style="${inStyle}" class="${amountCol}" value="${acc.balance}" onfocus="this.style.background='var(--color-widget-background-highlight)'" onblur="this.style.background='transparent'" onchange="financeApp.update(${i},'balance',parseFloat(this.value))">`;
|
||||
|
||||
const td3 = document.createElement('td');
|
||||
td3.style.padding = '0';
|
||||
td3.innerHTML = `<input style="${inStyle}" value="${acc.currency}" onfocus="this.style.background='var(--color-widget-background-highlight)'" onblur="this.style.background='transparent'" onchange="financeApp.update(${i},'currency',this.value)">`;
|
||||
|
||||
const td4 = document.createElement('td');
|
||||
td4.style.textAlign = 'right';
|
||||
td4.style.padding = '1rem';
|
||||
td4.innerHTML = `<button onclick="financeApp.delete(${i})" style="padding: 0.4rem 0.8rem; border-radius: 6px; border: none; background: rgba(255, 50, 50, 0.15); color: var(--color-negative); font-weight: 600; cursor: pointer;">Delete</button>`;
|
||||
|
||||
tr.appendChild(td1);
|
||||
tr.appendChild(td2);
|
||||
tr.appendChild(td3);
|
||||
tr.appendChild(td4);
|
||||
t.appendChild(tr);
|
||||
});
|
||||
},
|
||||
update(i, key, val) { this.data.accounts[i][key] = val; this.renderTable(); this.save(); },
|
||||
addAccount() { this.data.accounts.push({name:'New Account',balance:0,currency:'USD'}); this.renderTable(); this.save(); },
|
||||
delete(i) { this.data.accounts.splice(i, 1); this.renderTable(); this.save(); }
|
||||
};
|
||||
window.financeApp.load();
|
||||
};
|
||||
|
||||
if (!window.financeAppLoaded) {
|
||||
window.financeAppLoaded = true;
|
||||
if (!window.Chart) {
|
||||
let script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/chart.js';
|
||||
script.onload = () => window.initFinanceApp();
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
window.initFinanceApp();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user