diff --git a/css/styles.css b/css/styles.css
index cec25fd..dcc401c 100644
--- a/css/styles.css
+++ b/css/styles.css
@@ -1026,6 +1026,89 @@ html, body {
font-weight: 700;
}
+/* ========================
+ Finance Charts
+ ======================== */
+.finance-charts {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+}
+
+.chart-section {
+ background: white;
+ border: 1px solid var(--border-color);
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+}
+
+.chart-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+ margin-bottom: 1rem;
+ color: var(--text-color);
+}
+
+.chart-container {
+ width: 100%;
+}
+
+.pie-chart-wrap {
+ display: flex;
+ align-items: flex-start;
+ gap: 2rem;
+ flex-wrap: wrap;
+}
+
+.pie-svg {
+ width: 200px;
+ height: 200px;
+ flex-shrink: 0;
+}
+
+.pie-legend {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ flex: 1;
+ min-width: 160px;
+}
+
+.pie-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.875rem;
+}
+
+.pie-legend-dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.pie-legend-label {
+ flex: 1;
+ color: var(--text-color);
+}
+
+.pie-legend-value {
+ color: var(--text-light);
+ white-space: nowrap;
+}
+
+.sankey-svg {
+ width: 100%;
+ height: auto;
+ max-height: 400px;
+}
+
+.sankey-label {
+ font-size: 11px;
+ fill: var(--text-color);
+}
+
/* ========================
Buttons
======================== */
diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts
index 99fe25f..19c15ef 100644
--- a/e2e/app.spec.ts
+++ b/e2e/app.spec.ts
@@ -227,8 +227,8 @@ test.describe('Task Manager App', () => {
await page.click('[data-finance-tab="revenue"]');
await expect(page.locator('#revenue-content')).toHaveClass(/active/);
- await page.click('[data-finance-tab="charges"]');
- await expect(page.locator('#charges-content')).toHaveClass(/active/);
+ await page.click('[data-finance-tab="charts"]');
+ await expect(page.locator('#charts-content')).toHaveClass(/active/);
await page.click('[data-finance-tab="expenses"]');
await expect(page.locator('#expenses-content')).toHaveClass(/active/);
diff --git a/index.html b/index.html
index 0e31231..8697652 100644
--- a/index.html
+++ b/index.html
@@ -436,7 +436,6 @@
-
-
No charges yet. Add one to track other expenses!
+
+
+
+
+
Expenses by Category
+
+
+
+
Income & Spending Flow
+
+
diff --git a/src/app.ts b/src/app.ts
index 18ac418..49b82f7 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -113,7 +113,6 @@ class TaskManager {
// Finances section
document.getElementById('addExpenseBtn')!.addEventListener('click', () => this.openFinanceModal('expense'));
document.getElementById('addRevenueBtn')!.addEventListener('click', () => this.openFinanceModal('revenue'));
- document.getElementById('addChargeBtn')!.addEventListener('click', () => this.openFinanceModal('charge'));
document.getElementById('financeForm')!.addEventListener('submit', (e) => this.saveFinance(e));
document.getElementById('cancelFinanceBtn')!.addEventListener('click', () => this.closeFinanceModal());
document.getElementById('deleteFinanceBtn')!.addEventListener('click', () => this.deleteFinance());
@@ -1556,21 +1555,19 @@ class TaskManager {
this.updateFinanceSummary();
this.renderExpenses();
this.renderRevenue();
- this.renderCharges();
+ this.renderCharts();
}
updateFinanceSummary(): void {
const expenses = this.filterFinanceItemsByDate(storage.getExpenses());
const revenue = this.filterFinanceItemsByDate(storage.getRevenue());
- const charges = this.filterFinanceItemsByDate(storage.getCharges());
const totalExpenses = expenses.reduce((sum, e) => sum + (e.monthlyAmount || 0), 0);
- const totalCharges = charges.reduce((sum, c) => sum + (c.monthlyAmount || 0), 0);
const totalRevenue = revenue.reduce((sum, r) => sum + (r.monthlyAmount || 0), 0);
- const net = totalRevenue - totalExpenses - totalCharges;
+ const net = totalRevenue - totalExpenses;
document.getElementById('totalIncome')!.textContent = '$' + totalRevenue.toFixed(2);
- document.getElementById('totalExpenses')!.textContent = '$' + (totalExpenses + totalCharges).toFixed(2);
+ document.getElementById('totalExpenses')!.textContent = '$' + totalExpenses.toFixed(2);
document.getElementById('netBalance')!.textContent = '$' + net.toFixed(2);
}
@@ -1584,9 +1581,153 @@ class TaskManager {
this.renderFinanceList(revenue, 'revenueList', 'revenue', true);
}
- renderCharges(): void {
- const charges = this.filterFinanceItemsByDate(storage.getCharges());
- this.renderFinanceList(charges, 'chargesList', 'charge');
+ renderCharts(): void {
+ this.renderExpensePieChart();
+ this.renderSankeyDiagram();
+ }
+
+ renderExpensePieChart(): void {
+ const container = document.getElementById('expensePieChart')!;
+ const expenses = this.filterFinanceItemsByDate(storage.getExpenses());
+
+ if (expenses.length === 0) {
+ container.innerHTML = '
No expenses to visualize.
';
+ return;
+ }
+
+ const categoryTotals: Record
= {};
+ for (const item of expenses) {
+ const cat = item.category || 'Uncategorized';
+ categoryTotals[cat] = (categoryTotals[cat] || 0) + (item.monthlyAmount || 0);
+ }
+
+ const entries = Object.entries(categoryTotals).sort((a, b) => b[1] - a[1]);
+ const total = entries.reduce((s, [, v]) => s + v, 0);
+
+ const colors = ['#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899','#14b8a6','#f97316','#6366f1','#84cc16'];
+ const cx = 140, cy = 140, r = 120;
+ let currentAngle = -Math.PI / 2;
+
+ const slices = entries.map(([cat, val], i) => {
+ const angle = (val / total) * 2 * Math.PI;
+ const x1 = cx + r * Math.cos(currentAngle);
+ const y1 = cy + r * Math.sin(currentAngle);
+ currentAngle += angle;
+ const x2 = cx + r * Math.cos(currentAngle);
+ const y2 = cy + r * Math.sin(currentAngle);
+ const largeArc = angle > Math.PI ? 1 : 0;
+ const path = `M${cx},${cy} L${x1.toFixed(2)},${y1.toFixed(2)} A${r},${r} 0 ${largeArc},1 ${x2.toFixed(2)},${y2.toFixed(2)} Z`;
+ return { path, color: colors[i % colors.length], cat, val };
+ });
+
+ const legendRows = entries.map(([cat, val], i) => `
+
+
+ ${cat}
+ $${val.toFixed(2)} (${((val / total) * 100).toFixed(1)}%)
+
`).join('');
+
+ container.innerHTML = `
+
+
+
${legendRows}
+
`;
+ }
+
+ renderSankeyDiagram(): void {
+ const container = document.getElementById('sankeyChart')!;
+ const expenses = this.filterFinanceItemsByDate(storage.getExpenses());
+ const revenue = this.filterFinanceItemsByDate(storage.getRevenue());
+
+ if (expenses.length === 0 && revenue.length === 0) {
+ container.innerHTML = 'No data to visualize.
';
+ return;
+ }
+
+ const revenueTotals: Record = {};
+ for (const item of revenue) {
+ const cat = item.category || item.description || 'Income';
+ revenueTotals[cat] = (revenueTotals[cat] || 0) + (item.monthlyAmount || 0);
+ }
+
+ const expenseTotals: Record = {};
+ for (const item of expenses) {
+ const cat = item.category || 'Uncategorized';
+ expenseTotals[cat] = (expenseTotals[cat] || 0) + (item.monthlyAmount || 0);
+ }
+
+ const totalRevenue = Object.values(revenueTotals).reduce((s, v) => s + v, 0);
+ const totalExpenses = Object.values(expenseTotals).reduce((s, v) => s + v, 0);
+ const totalFlow = Math.max(totalRevenue, totalExpenses, 0.01);
+
+ const svgW = 560, svgH = 320;
+ const nodeW = 14, lx = 20, rx = svgW - nodeW - 20;
+ const colors = ['#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899','#14b8a6','#f97316','#6366f1','#84cc16'];
+ const incomeColor = '#10b981';
+ const padding = 8;
+
+ const revEntries = Object.entries(revenueTotals);
+ const expEntries = Object.entries(expenseTotals);
+
+ const totalLeftH = svgH - padding * (revEntries.length - 1);
+ const totalRightH = svgH - padding * (expEntries.length - 1);
+
+ let leftY = 0;
+ const leftNodes = revEntries.map(([cat, val]) => {
+ const h = Math.max(4, (val / totalFlow) * totalLeftH);
+ const node = { cat, val, y: leftY, h };
+ leftY += h + padding;
+ return node;
+ });
+
+ let rightY = 0;
+ const rightNodes = expEntries.map(([cat, val], i) => {
+ const h = Math.max(4, (val / totalFlow) * totalRightH);
+ const node = { cat, val, y: rightY, h, color: colors[i % colors.length] };
+ rightY += h + padding;
+ return node;
+ });
+
+ const leftPaths = leftNodes.map(ln => {
+ // Each income source distributes proportionally across all expense categories
+ let flowY = ln.y;
+ return rightNodes.map(rn => {
+ // Proportional share: income node's share of total * expense node's share of total
+ const flowFraction = (ln.val / totalFlow) * (rn.val / totalFlow) * totalFlow;
+ const flowH = Math.max(1, (flowFraction / totalFlow) * totalLeftH);
+ const x1 = lx + nodeW;
+ const y1top = flowY;
+ const y1bot = flowY + flowH;
+ const y2top = rn.y + (rn.h * (ln.y / (leftY || 1)));
+ const y2bot = y2top + flowH;
+ const mx = x1 + (rx - x1) * 0.5;
+ const topPath = `M${x1},${y1top} C${mx},${y1top} ${mx},${y2top} ${rx},${y2top}`;
+ const botPath = `L${rx},${y2bot} C${mx},${y2bot} ${mx},${y1bot} ${x1},${y1bot}`;
+ flowY += flowH;
+ return ``;
+ }).join('');
+ }).join('');
+
+ const leftRects = leftNodes.map(n =>
+ `
+ ${n.cat} ($${n.val.toFixed(0)})`
+ ).join('');
+
+ const rightRects = rightNodes.map(n =>
+ `
+ ${n.cat} ($${n.val.toFixed(0)})`
+ ).join('');
+
+ container.innerHTML = `
+ `;
}
renderFinanceList(items: FilteredFinanceItem[], containerId: string, type: string, isIncome: boolean = false): void {
@@ -1640,14 +1781,13 @@ class TaskManager {
recurringGroup.style.display = ['expense', 'revenue'].includes(type) ? 'block' : 'none';
- const titles: Record = { expense: 'Add Expense', revenue: 'Add Revenue', charge: 'Add Other Charge' };
+ const titles: Record = { expense: 'Add Expense', revenue: 'Add Revenue' };
document.getElementById('financeModalTitle')!.textContent = financeId ? `Edit ${type}` : titles[type];
if (financeId) {
let item: FinanceItem | undefined;
if (type === 'expense') item = storage.getExpenses().find(e => e.id === financeId);
else if (type === 'revenue') item = storage.getRevenue().find(r => r.id === financeId);
- else if (type === 'charge') item = storage.getCharges().find(c => c.id === financeId);
if (item) {
(document.getElementById('financeDescription') as HTMLInputElement).value = item.description;
@@ -1696,12 +1836,6 @@ class TaskManager {
} else {
storage.addRevenue(financeItem);
}
- } else if (this.currentEditingFinanceType === 'charge') {
- if (this.currentEditingFinanceId) {
- storage.updateCharge(this.currentEditingFinanceId, financeItem);
- } else {
- storage.addCharge(financeItem);
- }
}
this.closeFinanceModal();
@@ -1715,8 +1849,6 @@ class TaskManager {
storage.deleteExpense(this.currentEditingFinanceId);
} else if (this.currentEditingFinanceType === 'revenue') {
storage.deleteRevenue(this.currentEditingFinanceId);
- } else if (this.currentEditingFinanceType === 'charge') {
- storage.deleteCharge(this.currentEditingFinanceId);
}
this.closeFinanceModal();
this.renderFinances();
diff --git a/tests/storage.test.ts b/tests/storage.test.ts
index 3681e0c..718adf0 100644
--- a/tests/storage.test.ts
+++ b/tests/storage.test.ts
@@ -310,27 +310,6 @@ describe('StorageManager', () => {
});
});
- describe('charge management', () => {
- it('should add a charge', () => {
- const charge = storage.addCharge({ description: 'Rent', amount: 1200 });
- expect(charge.id).toBeDefined();
- expect(charge.description).toBe('Rent');
- });
-
- it('should update a charge', () => {
- const charge = storage.addCharge({ description: 'Rent', amount: 1200 });
- storage.updateCharge(charge.id, { amount: 1300 });
- const updated = storage.getCharges().find(c => c.id === charge.id);
- expect(updated?.amount).toBe(1300);
- });
-
- it('should delete a charge', () => {
- const charge = storage.addCharge({ description: 'Rent', amount: 1200 });
- storage.deleteCharge(charge.id);
- expect(storage.getCharges().length).toBe(0);
- });
- });
-
// ========================
// Leveling & Streak
// ========================