<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynamic Budget App</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.3.0/papaparse.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!--
Chosen Palette: Warm Neutrals (Slate/Gray with Teal accent)
Application Structure Plan: The application now features a dynamic "Landing Page" for CSV file upload and management of previously saved dashboards. Users can upload new CSVs or load, rename, or delete existing budgets from local storage. Upon loading a budget, the user transitions to the "Dashboard View." This dashboard is structured top-down: Summary Metrics provide a quick financial snapshot, followed by Visualizations (Donut for expenses, Bar for income) for graphical insights. The "Detailed Income Sources" list offers a textual overview of income. Finally, the "Transaction Details" table provides granular, editable control over individual transactions, with dynamic filtering and search, and now includes direct editing of description, type (income/expense), and deletion. An integrated "Chat Interface" provides analytical queries and informational responses, simulating interaction. This structure prioritizes user flow from setup to high-level understanding, then to detailed exploration and comprehensive manual editing, providing an intuitive and persistent path for budget management.
Visualization & Content Choices:
- Landing Page (Enhanced): Goal: Onboarding & Dashboard Management. Method: HTML form for upload, dynamically generated list for saved dashboards. Interaction: File selection, button click to load/save new, click to load/rename/delete existing. Justification: Essential for dynamic data input and managing multiple persisted budgets.
- Dashboard Controls (New): Goal: Dashboard Management. Method: Buttons for Rename, Export CSV. Interaction: Click to trigger respective actions. Justification: Provides high-level control over the currently loaded budget.
- Key Metrics: Goal: Inform. Method: Large text cards. Justification: Immediate, scannable financial status. Updates dynamically.
- Expense Breakdown: Goal: Compare proportions. Method: Donut Chart (Chart.js/Canvas). Interaction: Hover tooltips. Justification: Effective for showing parts-of-a-whole, ideal for spending distribution. Updates dynamically.
- Income Breakdown: Goal: Compare distinct values. Method: Bar Chart (Chart.js/Canvas). Interaction: Hover tooltips. Justification: Better than pie for comparing discrete income sources. Updates dynamically.
- Income Sources List: Goal: Inform. Method: Simple HTML list. Justification: Clear textual summary complementing the chart. Updates dynamically.
- Full Transaction List (Enhanced): Goal: Organize & Explore, Edit. Method: Sortable and filterable HTML table. Interaction: Text search, category dropdown filter, *editable text input for description*, *dropdown for type (Income/Expense)*, *delete button*. Justification: Granular investigation and core editing functionality for each transaction. Updates dynamically.
- Chat Interface: Goal: Query & Inform. Method: HTML text area and button. Interaction: Text input, button click for query, display of text responses. Justification: Provides a query interface, explaining LLM limitations and offering basic data insights.
CONFIRMATION: NO SVG graphics used. NO Mermaid JS used.
-->
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #f8fafc; /* slate-50 */
}
.chart-container {
position: relative;
width: 100%;
max-width: 500px;
height: auto;
min-height: 300px;
margin: auto;
}
@media (min-width: 768px) {
.chart-container {
min-height: 400px;
}
}
.table-container {
max-height: 500px;
overflow-y: auto;
}
/* Custom scrollbar for aesthetics */
.table-container::-webkit-scrollbar {
width: 8px;
}
.table-container::-webkit-scrollbar-track {
background: #f1f5f9;
}
.table-container::-webkit-scrollbar-thumb {
background-color: #94a3b8;
border-radius: 10px;
border: 2px solid #f1f5f9;
}
.chat-container {
position: fixed;
bottom: 20px;
right: 20px;
width: 300px;
background-color: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
z-index: 1000;
display: flex;
flex-direction: column;
max-height: 80vh;
}
.chat-header {
background-color: #14b8a6;
color: white;
padding: 12px;
font-weight: 600;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-body {
flex-grow: 1;
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 0.875rem;
}
.chat-input-area {
display: flex;
padding: 12px;
border-top: 1px solid #e2e8f0;
}
.chat-message {
max-width: 85%;
padding: 8px 12px;
border-radius: 8px;
}
.chat-message.user {
background-color: #e0f7fa;
align-self: flex-end;
text-align: right;
color: #00796b;
}
.chat-message.bot {
background-color: #f1f5f9;
align-self: flex-start;
color: #334155;
}
.loading-dots span {
animation: blink 1.4s infinite linear;
animation-fill-mode: both;
}
.loading-dots span:nth-child(2) {
animation-delay: .2s;
}
.loading-dots span:nth-child(3) {
animation-delay: .4s;
}
@keyframes blink {
0% { opacity: .2; }
20% { opacity: 1; }
100% { opacity: .2; }
}
@media (max-width: 767px) {
.chat-container {
width: 90%;
left: 5%;
bottom: 10px;
max-height: 70vh;
}
}
</style>
</head>
<body class="text-slate-800">
<!-- Landing Page - File Upload & Previous Dashboards -->
<div id="landingPage" class="min-h-screen flex flex-col items-center justify-center p-4">
<div class="bg-white p-8 rounded-xl shadow-md border border-slate-200 w-full max-w-md mb-8 text-center">
<h2 class="text-2xl font-bold text-slate-900 mb-4">Upload New Budget Data</h2>
<p class="text-slate-500 mb-6">Upload your banking transactions CSV file to create a new dashboard.</p>
<input type="file" id="csvFileInput" accept=".csv" class="block w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-teal-50 file:text-teal-700 hover:file:bg-teal-100 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2">
<input type="text" id="dashboardNameInput" placeholder="Enter dashboard name (e.g., May 2025 Budget)" class="mt-4 w-full p-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none">
<button id="loadDashboardBtn" class="mt-6 w-full bg-teal-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 transition duration-200 ease-in-out">
Load & Save Dashboard
</button>
<p id="uploadError" class="text-red-500 text-sm mt-4 hidden">Please select a CSV file and enter a name.</p>
</div>
<div class="bg-white p-8 rounded-xl shadow-md border border-slate-200 w-full max-w-md">
<h2 class="text-2xl font-bold text-slate-900 mb-4 text-center">Previous Dashboards</h2>
<ul id="previousDashboardsList" class="divide-y divide-slate-100">
<li class="py-2 text-center text-slate-500">No dashboards saved yet.</li>
</ul>
</div>
</div>
<!-- Main Dashboard -->
<div id="mainDashboard" class="container mx-auto p-4 md:p-8 hidden">
<header class="text-center mb-8">
<h1 id="dashboardTitle" class="text-3xl md:text-4xl font-bold text-slate-900">Personal Finance Dashboard</h1>
<p class="text-slate-500 mt-2">An interactive overview of your income and expenses.</p>
<div class="flex flex-wrap justify-center gap-4 mt-4">
<button id="renameCurrentDashboardBtn" class="bg-slate-200 text-slate-700 py-2 px-4 rounded-lg font-semibold hover:bg-slate-300 transition duration-200 ease-in-out">Rename Dashboard</button>
<button id="exportCsvBtn" class="bg-teal-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-teal-700 transition duration-200 ease-in-out">Export to CSV</button>
<button id="backToHomeBtn" class="bg-slate-500 text-white py-2 px-4 rounded-lg font-semibold hover:bg-slate-600 transition duration-200 ease-in-out">Back to Home</button>
</div>
</header>
<main>
<!-- Key Metrics Section -->
<section id="metrics" class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6 text-center">
<div class="bg-white p-6 rounded-xl shadow-md border border-slate-200">
<h3 class="text-lg font-semibold text-slate-500">Total Income</h3>
<p id="totalIncome" class="text-3xl font-bold text-teal-600 mt-2">R0.00</p>
</div>
<div class="bg-white p-6 rounded-xl shadow-md border border-slate-200">
<h3 class="text-lg font-semibold text-slate-500">Total Expenses</h3>
<p id="totalExpenses" class="text-3xl font-bold text-red-500 mt-2">R0.00</p>
</div>
<div class="bg-white p-6 rounded-xl shadow-md border border-slate-200">
<h3 class="text-lg font-semibold text-slate-500">Net Balance</h3>
<p id="netBalance" class="text-3xl font-bold text-slate-700 mt-2">R0.00</p>
</div>
</div>
</section>
<!-- Charts Section -->
<section id="visualizations" class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<div class="bg-white p-6 rounded-xl shadow-md border border-slate-200">
<h2 class="text-xl font-bold text-center mb-4">Expense Breakdown</h2>
<p class="text-center text-slate-500 text-sm mb-4">This chart shows the proportion of your spending for each category. Hover over a slice to see the total amount.</p>
<div class="chart-container">
<canvas id="expenseChart"></canvas>
</div>
</div>
<div class="bg-white p-6 rounded-xl shadow-md border border-slate-200">
<h2 class="text-xl font-bold text-center mb-4">Income Sources Chart</h2>
<p class="text-center text-slate-500 text-sm mb-4">This chart compares your various sources of income. Hover over a bar to see the total amount.</p>
<div class="chart-container">
<canvas id="incomeChart"></canvas>
</div>
</div>
</section>
<!-- Income Sources List Section -->
<section id="income-list" class="mb-8">
<div class="bg-white p-6 rounded-xl shadow-md border border-slate-200">
<h2 class="text-xl font-bold mb-4">Detailed Income Sources</h2>
<p class="text-slate-500 text-sm mb-4">This section provides a textual list of all your income categories and their respective totals, offering a quick summary alongside the chart.</p>
<ul id="incomeSourceList" class="divide-y divide-slate-100">
<!-- Income sources will be populated here by JavaScript -->
</ul>
</div>
</section>
<!-- Transactions Section -->
<section id="transactions">
<div class="bg-white p-6 rounded-xl shadow-md border border-slate-200">
<h2 class="text-xl font-bold mb-4">Transaction Details</h2>
<p class="text-slate-500 text-sm mb-4">Here is a complete list of all your transactions. You can use the search box to filter by description or select a category from the dropdown to narrow down the results. You can also edit details directly.</p>
<div class="flex flex-col md:flex-row gap-4 mb-4">
<input type="text" id="searchInput" placeholder="Search transactions..." class="w-full md:w-1/3 p-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none">
<select id="categoryFilter" class="w-full md:w-1/3 p-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none">
<option value="all">All Categories</option>
</select>
</div>
<div class="table-container border border-slate-200 rounded-lg">
<table class="w-full text-sm text-left text-slate-500">
<thead class="text-xs text-slate-700 uppercase bg-slate-100 sticky top-0">
<tr>
<th scope="col" class="px-2 py-3">Date</th>
<th scope="col" class="px-2 py-3">Description</th>
<th scope="col" class="px-2 py-3">Category</th>
<th scope="col" class="px-2 py-3">Type</th>
<th scope="col" class="px-2 py-3 text-right">Amount (R)</th>
<th scope="col" class="px-2 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody id="transactionTableBody">
<!-- JS will populate this -->
</tbody>
</table>
</div>
</div>
</section>
</main>
<footer class="text-center mt-12 py-4 border-t border-slate-200">
<p class="text-slate-500 text-sm">Dashboard generated on <span id="generationDate"></span>.</p>
</footer>
</div>
<!-- Chat Interface -->
<div id="chatWidget" class="chat-container">
<div class="chat-header" id="chatHeader">
<span>Budget Assistant Chat</span>
<span id="chatToggle" class="text-xl leading-none">−</span>
</div>
<div id="chatBody" class="chat-body hidden">
<div class="chat-message bot">Hello! I'm your Budget Assistant. Ask me about your budget data. (e.g., "What are my top expenses?")</div>
</div>
<div id="chatInputArea" class="chat-input-area hidden">
<input type="text" id="chatInput" placeholder="Ask a question..." class="flex-grow p-2 border border-slate-300 rounded-l-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none">
<button id="chatSendBtn" class="bg-teal-600 text-white py-2 px-4 rounded-r-lg font-semibold hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 transition duration-200 ease-in-out">
Send
</button>
</div>
</div>
<script>
let allTransactions = []; // Holds the transactions for the currently loaded dashboard
let currentDashboardId = null; // ID of the currently loaded dashboard
let currentDashboardName = ''; // Name of the currently loaded dashboard
let incomeChartInstance;
let expenseChartInstance;
const LOCAL_STORAGE_KEY = 'budgetDashboards';
const formatCurrency = (value) => `R${value.toLocaleString('en-ZA', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
// --- Local Storage Management ---
function saveDashboard(id, name, transactions) {
let dashboards = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}');
dashboards[id] = {
id: id,
name: name,
timestamp: new Date().toISOString(),
transactions: transactions
};
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(dashboards));
renderPreviousDashboards(); // Refresh the list on the landing page
}
function loadDashboardData(id) {
let dashboards = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}');
return dashboards[id] ? JSON.parse(JSON.stringify(dashboards[id])) : null; // Deep copy to prevent direct mutation
}
function deleteDashboard(id) {
if (confirm('Are you sure you want to delete this dashboard? This cannot be undone.')) {
let dashboards = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}');
delete dashboards[id];
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(dashboards));
renderPreviousDashboards();
if (currentDashboardId === id) {
showLandingPage(); // Go back to home if current dashboard is deleted
}
}
}
function renameDashboard(id) {
let dashboards = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}');
const dashboard = dashboards[id];
if (!dashboard) return;
const newName = prompt(`Rename dashboard "${dashboard.name}":`, dashboard.name);
if (newName && newName.trim() !== '' && newName !== dashboard.name) {
dashboard.name = newName.trim();
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(dashboards));
if (currentDashboardId === id) {
document.getElementById('dashboardTitle').textContent = `${newName.trim()} Dashboard`;
}
renderPreviousDashboards();
}
}
// --- UI State Management ---
function showLandingPage() {
document.getElementById('landingPage').classList.remove('hidden');
document.getElementById('mainDashboard').classList.add('hidden');
currentDashboardId = null;
currentDashboardName = '';
allTransactions = []; // Clear current transactions
renderPreviousDashboards(); // Ensure previous dashboards list is up-to-date
}
function showDashboard() {
document.getElementById('landingPage').classList.add('hidden');
document.getElementById('mainDashboard').classList.remove('hidden');
}
// --- Data Categorization Logic ---
function categorizeTransaction(description, amount) {
description = description.toLowerCase();
if (amount > 0) { // Income
if (description.includes('payoneer inc')) return 'TRT World';
if (description.includes('fnb ob pmt') && description.includes('redletter')) return 'StoryBrand';
if (description.includes('fnb app transfer from')) return 'Internal Transfer In';
if (description.includes('internet trf from')) return 'Internal Transfer In';
if (description.includes('dad') || description.includes('your peanut')) return 'Transfers In (Family)';
if (description.includes('magtape')) return 'Other Income';
if (description.includes('sd10091773') || description.includes('1001015758')) return 'Other Income';
if (description.includes('wbcvehiclewe buy cars refund')) return 'Vehicle Sale/Refund';
if (description.includes('fnb loan')) return 'Loan Payout / Income';
if (description.includes('hertz rent a car')) return 'Rental Income (Hertz)';
if (description.includes('mobicred')) return 'Credit/Mobicred Top-up';
if (description.includes('avianto')) return 'Transfers In';
if (description.includes('#rev int pymt fee') || description.includes('#slow lounge fee rev')) return 'Bank Fees & Charges (Reversal)';
if (description.includes('voucher') || description.includes('google *ads')) return 'Voucher/Credit';
if (description.includes('interest adjustment') || description.includes('interest rebate adjustment cr')) return 'Interest Income';
} else { // Expense
if (description.includes('#fee') || description.includes('#int pymt fee') || description.includes('#monthly account fee') || description.includes('#monthly credit fee') || description.includes('int on debit balance') || description.includes('#slow lounge fee') || description.includes('byc debit')) return 'Bank Fees & Charges';
if (description.includes('netflix.com') || description.includes('apple.com/b') || description.includes('disney plus') || description.includes('playstation network') || description.includes('amazon.ca prime mem') || description.includes('google one') || description.includes('adobe')) return 'Subscriptions & Digital Services';
if (description.includes('woolworths') || description.includes('checkers') || description.includes('pnp') || description.includes('superspar')) return 'Groceries';
if (description.includes('starbucks') || description.includes('vida e caffe') || description.includes('mugg and bean') || description.includes('yoco') || description.includes('mcd') || description.includes('wimpy') || description.includes('bossa good time res') || description.includes('salsa mexican g') || description.includes('bk meyersdal u') || description.includes('kfc') || description.includes('doppio zero') || description.includes('moo moo pineslopes') || description.includes('pizza perfect') || description.includes('sailors old fashion') || description.includes('oven delights baker') || description.includes('the butcher shop an') || description.includes('naked coffee') || description.includes('joy jozi dunkeld') || description.includes('rocomamas mall of t')) return 'Dining & Cafes';
if (description.includes('engen') || description.includes('sasol') || description.includes('caltex') || description.includes('shell') || description.includes('admyt parking') || description.includes('uber') || description.includes('hertz rent a car') || description.includes('wesbank')) return 'Fuel & Transportation';
if (description.includes('sun city vacation c') || description.includes('pilansburg game res') || description.includes('wtt time flies toti') || description.includes('acsa jia') || description.includes('nu metro')) return 'Travel & Leisure';
if (description.includes('payflex') || description.includes('makro') || description.includes('marble pantry') || description.includes('magic company kiosk') || description.includes('the cand5') || description.includes('candylicious') || description.includes('oriental spice baza') || description.includes('pep home') || description.includes('pna new market') || description.includes('opus clip') || description.includes('mtn card save') || description.includes('sinderella costume') || description.includes('advance the glen sh') || description.includes('alberton battery hq') || description.includes('the local choice ph') || description.includes('panda stores') || description.includes('westpack express ra') || description.includes('clicks rand steam') || description.includes('glenvista flora') || description.includes('junxion pharmacy') || description.includes('itickets') || description.includes('mtn sp') || description.includes('appliances') || description.includes('pluckys') || description.includes('candy galore domest')) return 'Shopping & Retail';
if (description.includes('rental') || description.includes('netvendor') || description.includes('mweb') || description.includes('balwinconn') || description.includes('afrihost') || description.includes('tracker') || description.includes('car balance') || description.includes('fevertree') || description.includes('jethro - little owls') || description.includes('playball') || description.includes('primemeridprimemerid') || description.includes('fnb insure')) return 'Home & Utilities / Recurring Payments';
if (description.includes('premium fitness cen') || description.includes('mulbarton pharmacy') || description.includes('comaro view opt') || description.includes('face it make up')) return 'Health & Wellness';
if (description.includes('fnbcc') || description.includes('sanlamgap') || description.includes('sl-debits sanlam')) return 'Loans & Debt Payments';
if (description.includes('cell cash') || description.includes('fdh subvlta') || description.includes('fdh subvlt') || description.includes('lotto purchase paid') || description.includes('dcre')) return 'Cash & Withdrawals';
if (description.includes('savings and sun city') || description.includes('sd10091773') || description.includes('1001015758')) return 'Savings & Investments';
if (description.includes('send') || description.includes('atos') || description.includes('peanut') || description.includes('cc')) return 'Transfers Out';
if (description.includes('unpaid') || description.includes('#item unpaid no funds')) return 'Unpaid Items/Declined';
if (description.includes('we buy cars refund')) return 'Refunds/Returns (Expense Adjustment)';
}
return 'Uncategorized';
}
// --- Core Dashboard Update Function ---
function updateDashboard() {
let incomeSummaryMap = new Map();
let expenseSummaryMap = new Map();
let minDate = null;
let maxDate = null;
allTransactions.forEach(t => {
const amount = parseFloat(t.amount);
const category = t.category;
const date = new Date(t.date);
if (!isNaN(date.getTime())) {
if (minDate === null || date < minDate) minDate = date;
if (maxDate === null || date > maxDate) maxDate = date;
}
if (amount > 0) {
incomeSummaryMap.set(category, (incomeSummaryMap.get(category) || 0) + amount);
} else {
expenseSummaryMap.set(category, (expenseSummaryMap.get(category) || 0) + Math.abs(amount));
}
});
// Convert maps to sorted arrays of objects for Chart.js and list rendering
const incomeData = Array.from(incomeSummaryMap, ([category, value]) => ({ category, value })).sort((a, b) => b.value - a.value);
const expenseData = Array.from(expenseSummaryMap, ([category, value]) => ({ category, value })).sort((a, b) => b.value - a.value);
// Update Key Metrics
const totalIncome = incomeData.reduce((sum, item) => sum + item.value, 0);
const totalExpenses = expenseData.reduce((sum, item) => sum + item.value, 0);
const netBalance = totalIncome - totalExpenses;
document.getElementById('totalIncome').textContent = formatCurrency(totalIncome);
document.getElementById('totalExpenses').textContent = formatCurrency(totalExpenses);
const netBalanceEl = document.getElementById('netBalance');
netBalanceEl.textContent = formatCurrency(netBalance);
netBalanceEl.classList.toggle('text-red-500', netBalance < 0);
netBalanceEl.classList.toggle('text-slate-700', netBalance >= 0);
// Update Date Range in Title
if (minDate && maxDate) {
const formatDateTitle = (date) => date.toISOString().split('T')[0];
document.getElementById('dashboardTitle').textContent = `${currentDashboardName} (${formatDateTitle(minDate)} to ${formatDateTitle(maxDate)})`;
} else {
document.getElementById('dashboardTitle').textContent = `${currentDashboardName} (No Date Data)`;
}
document.getElementById('generationDate').textContent = new Date().toLocaleDateString('en-ZA', { year: 'numeric', month: 'long', day: 'numeric' });
// Chart Colors
const chartColors = [
'#14b8a6', '#0891b2', '#0284c7', '#4f46e5', '#7c3aed', '#c026d3', '#db2777', '#e11d48',
'#f97316', '#facc15', '#84cc16', '#22c55e', '#64748b', '#71717a', '#a16207', '#4d7c0f'
];
// Update Expense Donut Chart
if (expenseChartInstance) {
expenseChartInstance.destroy();
}
const expenseCtx = document.getElementById('expenseChart').getContext('2d');
expenseChartInstance = new Chart(expenseCtx, {
type: 'doughnut',
data: {
labels: expenseData.map(item => item.category),
datasets: [{
label: 'Expenses',
data: expenseData.map(item => item.value),
backgroundColor: chartColors,
borderColor: '#f8fafc',
borderWidth: 2,
hoverOffset: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
let label = context.label || '';
if (label) { label += ': '; }
if (context.parsed !== null) { label += formatCurrency(context.parsed); }
return label;
}
}
}
}
}
});
// Update Income Bar Chart
if (incomeChartInstance) {
incomeChartInstance.destroy();
}
const incomeCtx = document.getElementById('incomeChart').getContext('2d');
incomeChartInstance = new Chart(incomeCtx, {
type: 'bar',
data: {
labels: incomeData.map(item => item.category),
datasets: [{
label: 'Income',
data: incomeData.map(item => item.value),
backgroundColor: chartColors,
borderColor: chartColors.map(color => color + 'cc'),
borderWidth: 1
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) { return ` ${formatCurrency(context.parsed.x)}`; }
}
}
},
scales: {
x: {
beginAtZero: true,
ticks: {
callback: function(value) { return 'R' + (value / 1000) + 'k'; }
}
}
}
}
});
// Update Income Sources List
const incomeSourceList = document.getElementById('incomeSourceList');
incomeSourceList.innerHTML = '';
if (incomeData.length === 0) {
incomeSourceList.innerHTML = '<li class="py-2 text-slate-500 text-center">No income transactions.</li>';
} else {
incomeData.forEach(item => {
const listItem = document.createElement('li');
listItem.className = 'flex justify-between items-center py-2';
listItem.innerHTML = `
<span class="text-slate-700">${item.category}</span>
<span class="font-semibold text-teal-600">${formatCurrency(item.value)}</span>
`;
incomeSourceList.appendChild(listItem);
});
}
// Save current state to local storage
if (currentDashboardId) {
saveDashboard(currentDashboardId, currentDashboardName, allTransactions);
}
// Re-render transaction table with current filters
filterAndRender();
}
function renderTable(transactionsToDisplay) {
const transactionTableBody = document.getElementById('transactionTableBody');
transactionTableBody.innerHTML = '';
if (transactionsToDisplay.length === 0) {
transactionTableBody.innerHTML = `<tr><td colspan="6" class="text-center p-8 text-slate-500">No transactions found.</td></tr>`;
return;
}
// Get all unique categories for the dropdowns
const allPossibleCategories = [...new Set(allTransactions.map(t => t.category))].sort();
if (!allPossibleCategories.includes('Uncategorized')) {
allPossibleCategories.unshift('Uncategorized');
}
transactionsToDisplay.forEach((t) => {
const row = document.createElement('tr');
row.className = 'bg-white border-b hover:bg-slate-50';
const isIncome = parseFloat(t.amount) > 0;
const amountClass = isIncome ? 'text-teal-600' : 'text-red-500';
// Description Input
const descriptionInput = document.createElement('input');
descriptionInput.type = 'text';
descriptionInput.value = t.description;
descriptionInput.className = 'w-full p-1 border border-slate-300 rounded-md focus:ring-1 focus:ring-teal-500';
descriptionInput.addEventListener('change', (e) => {
t.description = e.target.value;
updateDashboard();
});
// Category Dropdown
const categoryDropdown = document.createElement('select');
categoryDropdown.className = 'w-full p-1 border border-slate-300 rounded-md bg-white text-slate-700 focus:ring-1 focus:ring-teal-500';
allPossibleCategories.forEach(cat => {
const option = document.createElement('option');
option.value = cat;
option.textContent = cat;
if (t.category === cat) {
option.selected = true;
}
categoryDropdown.appendChild(option);
});
categoryDropdown.addEventListener('change', (e) => {
t.category = e.target.value;
updateDashboard();
});
// Type Dropdown (Income/Expense)
const typeDropdown = document.createElement('select');
typeDropdown.className = 'p-1 border border-slate-300 rounded-md bg-white text-slate-700 focus:ring-1 focus:ring-teal-500';
const incomeOption = document.createElement('option');
incomeOption.value = 'income';
incomeOption.textContent = 'Income';
const expenseOption = document.createElement('option');
expenseOption.value = 'expense';
expenseOption.textContent = 'Expense';
typeDropdown.appendChild(incomeOption);
typeDropdown.appendChild(expenseOption);
if (isIncome) {
incomeOption.selected = true;
} else {
expenseOption.selected = true;
}
typeDropdown.addEventListener('change', (e) => {
const currentAmount = parseFloat(t.amount);
if (e.target.value === 'income' && currentAmount < 0) {
t.amount = Math.abs(currentAmount);
} else if (e.target.value === 'expense' && currentAmount > 0) {
t.amount = -Math.abs(currentAmount);
}
updateDashboard();
});
// Amount Display (Directly editable for value, type dropdown handles sign)
const amountInput = document.createElement('input');
amountInput.type = 'number';
amountInput.step = '0.01';
amountInput.value = Math.abs(parseFloat(t.amount) || 0).toFixed(2); // Show absolute value
amountInput.className = 'w-full text-right p-1 border border-slate-300 rounded-md focus:ring-1 focus:ring-teal-500';
amountInput.addEventListener('change', (e) => {
let newValue = parseFloat(e.target.value);
if (isNaN(newValue)) {
newValue = 0;
}
// Apply sign based on type dropdown
if (typeDropdown.value === 'expense') {
t.amount = -Math.abs(newValue);
} else {
t.amount = Math.abs(newValue);
}
updateDashboard();
});
// Delete Button
const deleteBtn = document.createElement('button');
deleteBtn.textContent = 'Delete';
deleteBtn.className = 'bg-red-500 text-white text-xs px-2 py-1 rounded hover:bg-red-600';
deleteBtn.addEventListener('click', () => {
if (confirm('Are you sure you want to delete this transaction?')) {
allTransactions = allTransactions.filter(item => item !== t); // Remove the transaction
updateDashboard();
}
});
row.innerHTML = `
<td class="px-2 py-2 whitespace-nowrap">${t.date}</td>
<td class="px-2 py-2"></td> <!-- For description input -->
<td class="px-2 py-2"></td> <!-- For category dropdown -->
<td class="px-2 py-2"></td> <!-- For type dropdown -->
<td class="px-2 py-2"></td> <!-- For amount input -->
<td class="px-2 py-2 text-right"></td> <!-- For actions -->
`;
row.children[1].appendChild(descriptionInput);
row.children[2].appendChild(categoryDropdown);
row.children[3].appendChild(typeDropdown);
row.children[4].appendChild(amountInput);
row.children[5].appendChild(deleteBtn);
transactionTableBody.appendChild(row);
});
}
function filterAndRender() {
const searchInput = document.getElementById('searchInput');
const categoryFilter = document.getElementById('categoryFilter');
const searchTerm = searchInput.value.toLowerCase();
const selectedCategory = categoryFilter.value;
const filteredTransactions = allTransactions.filter(t => {
const matchesSearch = t.description.toLowerCase().includes(searchTerm);
const matchesCategory = selectedCategory === 'all' || t.category === selectedCategory;
return matchesSearch && matchesCategory;
});
renderTable(filteredTransactions);
}
// --- Dashboard Operations (Rename, Export, Back to Home) ---
document.getElementById('renameCurrentDashboardBtn').addEventListener('click', () => {
if (currentDashboardId) {
renameDashboard(currentDashboardId);
} else {
alert('No dashboard is currently loaded to rename.');
}
});
document.getElementById('exportCsvBtn').addEventListener('click', () => {
if (allTransactions.length === 0) {
alert('No data to export.');
return;
}
// Reconstruct data for CSV export
const csvData = [
['Date', 'Description', 'Category', 'Amount', 'Type'] // Header row
];
allTransactions.forEach(t => {
const type = parseFloat(t.amount) > 0 ? 'Income' : 'Expense';
csvData.push([t.date, t.description, t.category, Math.abs(t.amount).toFixed(2), type]);
});
const csvContent = Papa.unparse(csvData);
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${currentDashboardName || 'budget_export'}_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(link.href);
});
document.getElementById('backToHomeBtn').addEventListener('click', showLandingPage);
// --- Chat Interface Logic ---
const chatHeader = document.getElementById('chatHeader');
const chatToggle = document.getElementById('chatToggle');
const chatBody = document.getElementById('chatBody');
const chatInputArea = document.getElementById('chatInputArea');
const chatInput = document.getElementById('chatInput');
const chatSendBtn = document.getElementById('chatSendBtn');
let chatMinimized = false;
chatHeader.addEventListener('click', () => {
chatMinimized = !chatMinimized;
chatBody.classList.toggle('hidden', chatMinimized);
chatInputArea.classList.toggle('hidden', chatMinimized);
chatToggle.textContent = chatMinimized ? '+' : '−';
});
function addMessage(text, sender) {
const messageDiv = document.createElement('div');
messageDiv.className = `chat-message ${sender}`;
messageDiv.innerHTML = text; // Use innerHTML to allow for loading dots animation
chatBody.appendChild(messageDiv);
chatBody.scrollTop = chatBody.scrollHeight; // Scroll to bottom
}
function handleChatCommand(command) {
addMessage(command, 'user');
addMessage('<span class="loading-dots"><span>.</span><span>.</span><span>.</span></span>', 'bot'); // Loading indicator
setTimeout(() => { // Simulate API delay
chatBody.lastChild.remove(); // Remove loading dots
command = command.toLowerCase().trim();
let response = "I'm sorry, I can only provide insights based on the loaded data. I cannot directly modify categories or data through this chat interface in this version of the app. Please use the dropdowns and input fields in the transaction table for editing. For advanced data manipulation or natural language editing, a full backend integration would be required.";
// Recompute incomeData and expenseData from allTransactions for chat queries
let currentIncomeSummaryMap = new Map();
let currentExpenseSummaryMap = new Map();
allTransactions.forEach(t => {
const amount = parseFloat(t.amount);
const category = t.category;
if (amount > 0) {
currentIncomeSummaryMap.set(category, (currentIncomeSummaryMap.get(category) || 0) + amount);
} else {
currentExpenseSummaryMap.set(category, (currentExpenseSummaryMap.get(category) || 0) + Math.abs(amount));
}
});
const currentIncomeData = Array.from(currentIncomeSummaryMap, ([category, value]) => ({ category, value }));
const currentExpenseData = Array.from(currentExpenseSummaryMap, ([category, value]) => ({ category, value }));
if (command.includes('top expenses') || command.includes('most expensive')) {
const topExpenses = currentExpenseData.sort((a, b) => b.value - a.value).slice(0, 3);
if (topExpenses.length > 0) {
response = "Your top expenses are:\n" + topExpenses.map(e => `- ${e.category}: ${formatCurrency(e.value)}`).join('\n');
} else {
response = "No expenses found to analyze.";
}
} else if (command.includes('total income')) {
const total = currentIncomeData.reduce((sum, item) => sum + item.value, 0);
response = `Your total income is: ${formatCurrency(total)}`;
} else if (command.includes('total expenses')) {
const total = currentExpenseData.reduce((sum, item) => sum + item.value, 0);
response = `Your total expenses are: ${formatCurrency(total)}`;
} else if (command.includes('net balance')) {
const totalInc = currentIncomeData.reduce((sum, item) => sum + item.value, 0);
const totalExp = currentExpenseData.reduce((sum, item) => sum + item.value, 0);
response = `Your net balance is: ${formatCurrency(totalInc - totalExp)}`;
} else if (command.includes('what is') && command.includes('income')) {
// Try to extract a category from the command
const categoryMatch = currentIncomeData.find(d => command.includes(d.category.toLowerCase()));
if (categoryMatch) {
response = `Your total income for ${categoryMatch.category} is ${formatCurrency(categoryMatch.value)}.`;
} else {
response = "Could not find specific income category. Try asking for 'total income' or a specific category like 'What is TRT World income?'";
}
} else if (command.includes('what is') && command.includes('expense')) {
// Try to extract a category from the command
const categoryMatch = currentExpenseData.find(d => command.includes(d.category.toLowerCase()));
if (categoryMatch) {
response = `Your total expense for ${categoryMatch.category} is ${formatCurrency(categoryMatch.value)}.`;
} else {
response = "Could not find specific expense category. Try asking for 'total expenses' or a specific category like 'What is Groceries expense?'";
}
}
addMessage(response, 'bot');
}, 1000); // Simulate network delay
}
chatSendBtn.addEventListener('click', () => {
const command = chatInput.value;
if (command.trim()) {
handleChatCommand(command);
chatInput.value = '';
}
});
chatInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
chatSendBtn.click();
}
});
// --- Page Initialization & Event Listeners ---
document.addEventListener('DOMContentLoaded', () => {
// Landing page upload button
document.getElementById('loadDashboardBtn').addEventListener('click', () => {
const fileInput = document.getElementById('csvFileInput');
const dashboardNameInput = document.getElementById('dashboardNameInput');
const uploadError = document.getElementById('uploadError');
if (fileInput.files.length === 0 || dashboardNameInput.value.trim() === '') {
uploadError.classList.remove('hidden');
return;
}
uploadError.classList.add('hidden');
const file = fileInput.files[0];
const newDashboardName = dashboardNameInput.value.trim();
const newDashboardId = `dashboard_${Date.now()}`; // Unique ID for the new dashboard
Papa.parse(file, {
header: false,
complete: function(results) {
const rawRows = results.data;
if (rawRows.length < 6) {
alert("CSV file is too short or malformed. Expected at least 6 rows including header.");
return;
}
const headerRow = rawRows[5];
const dataRows = rawRows.slice(6);
let dateIdx = -1, amountIdx = -1, descriptionIdx = -1;
headerRow.forEach((col, idx) => {
const cleanCol = col.toString().trim();
if (cleanCol === 'Date') dateIdx = idx;
if (cleanCol.includes('Amount')) amountIdx = idx; // Use includes for Amount
if (cleanCol === 'Description') descriptionIdx = idx;
});
if (dateIdx === -1 || amountIdx === -1 || descriptionIdx === -1) {
alert("Required columns (Date, Amount, Description) not found in the CSV header.");
return;
}
const newTransactions = dataRows.map(row => {
if (row.length > Math.max(dateIdx, amountIdx, descriptionIdx)) {
const description = row[descriptionIdx] || '';
const amount = parseFloat(row[amountIdx]) || 0;
const category = categorizeTransaction(description, amount); // Initial categorization
return {
id: `txn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // Unique ID for transaction
date: row[dateIdx],
amount: amount,
description: description,
category: category
};
}
return null;
}).filter(t => t !== null);
// Load the new dashboard
currentDashboardId = newDashboardId;
currentDashboardName = newDashboardName;
allTransactions = newTransactions;
showDashboard();
updateDashboard(); // Render and save the new dashboard
// Clear form fields
fileInput.value = '';
dashboardNameInput.value = '';
},
error: function(err, file) {
alert('Error parsing CSV file: ' + err.message);
console.error('PapaParse error:', err, file);
}
});
});
// Initial render of previous dashboards on page load
renderPreviousDashboards();
// Setup filters for transaction table
document.getElementById('searchInput').addEventListener('input', filterAndRender);
document.getElementById('categoryFilter').addEventListener('change', filterAndRender);
});
function renderPreviousDashboards() {
const listElement = document.getElementById('previousDashboardsList');
listElement.innerHTML = '';
let dashboards = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}');
const dashboardArray = Object.values(dashboards).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); // Sort by most recent
if (dashboardArray.length === 0) {
listElement.innerHTML = '<li class="py-2 text-center text-slate-500">No dashboards saved yet.</li>';
return;
}
dashboardArray.forEach(dash => {
const listItem = document.createElement('li');
listItem.className = 'flex flex-col md:flex-row justify-between items-center py-3 px-2 hover:bg-slate-50 rounded-lg';
const nameSpan = document.createElement('span');
nameSpan.className = 'font-semibold text-slate-700 text-base md:text-lg mb-2 md:mb-0';
nameSpan.textContent = dash.name;
const actionsDiv = document.createElement('div');
actionsDiv.className = 'flex flex-wrap justify-center gap-2';
const loadBtn = document.createElement('button');
loadBtn.textContent = 'Load';
loadBtn.className = 'bg-teal-500 text-white text-sm px-3 py-1 rounded-md hover:bg-teal-600 transition';
loadBtn.addEventListener('click', () => {
const loadedDash = loadDashboardData(dash.id);
if (loadedDash) {
currentDashboardId = loadedDash.id;
currentDashboardName = loadedDash.name;
allTransactions = loadedDash.transactions;
showDashboard();
updateDashboard(); // Render loaded dashboard
}
});
const renameBtn = document.createElement('button');
renameBtn.textContent = 'Rename';
renameBtn.className = 'bg-slate-300 text-slate-700 text-sm px-3 py-1 rounded-md hover:bg-slate-400 transition';
renameBtn.addEventListener('click', () => renameDashboard(dash.id));
const deleteBtn = document.createElement('button');
deleteBtn.textContent = 'Delete';
deleteBtn.className = 'bg-red-500 text-white text-sm px-3 py-1 rounded-md hover:bg-red-600 transition';
deleteBtn.addEventListener('click', () => deleteDashboard(dash.id));
actionsDiv.appendChild(loadBtn);
actionsDiv.appendChild(renameBtn);
actionsDiv.appendChild(deleteBtn);
listItem.appendChild(nameSpan);
listItem.appendChild(actionsDiv);
listElement.appendChild(listItem);
});
}
</script>
</body>
</html>