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 @@

Finances

-
@@ -468,11 +467,11 @@

Finances

- +
- +
@@ -489,10 +488,17 @@

Finances

- -
-
-

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 = ` +
+ + ${slices.map(s => ` + ${s.cat}: $${s.val.toFixed(2)} + `).join('')} + +
${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 = ` + + ${leftPaths} + ${leftRects} + ${rightRects} + `; } 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 // ========================