-
-
-
-
- Add
-
-
- ${cats.map(c => renderItem(c)).join('')}
- ${cats.length === 0 ? `
` : ''}
-
- `;
-
- document.getElementById('cat-add-form').onsubmit = async (e) => {
- e.preventDefault();
- const name = document.getElementById('cat-new-name').value.trim();
- if (!name) return;
- try {
- await API.createCategory(name);
- document.getElementById('cat-new-name').value = '';
- showToast('Category added', 'success');
- render(container);
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- }
- };
-
- container.querySelectorAll('.btn-delete-cat').forEach(btn => {
- btn.onclick = async () => {
- if (!confirm('Delete this category? Bills using it will be uncategorized.')) return;
- try {
- await API.deleteCategory(btn.dataset.id);
- showToast('Category deleted', 'success');
- render(container);
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- }
- };
- });
-
- container.querySelectorAll('.cat-name-span').forEach(span => {
- span.ondblclick = () => startRename(span, cats.find(c => c.id == span.dataset.id), container);
- });
- }
-
- function renderItem(cat) {
- return `
-
-
- ${escHtml(cat.name)}
-
- Delete
-
- `;
- }
-
- function startRename(span, cat, container) {
- const input = document.createElement('input');
- input.type = 'text';
- input.value = cat.name;
- input.className = 'cat-name';
- input.style.flex = '1';
- span.replaceWith(input);
- input.focus();
- input.select();
-
- async function commit() {
- const name = input.value.trim();
- if (!name || name === cat.name) { render(container); return; }
- try {
- await API.updateCategory(cat.id, name);
- showToast('Renamed', 'success');
- render(container);
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- render(container);
- }
- }
-
- input.addEventListener('blur', commit);
- input.addEventListener('keydown', e => {
- if (e.key === 'Enter') input.blur();
- if (e.key === 'Escape') render(container);
- });
- }
-
- return { init };
-})();
diff --git a/legacy/js/settings.js b/legacy/js/settings.js
deleted file mode 100644
index dd864c2..0000000
--- a/legacy/js/settings.js
+++ /dev/null
@@ -1,207 +0,0 @@
-/* ββ Settings page ββ */
-
-const SettingsPage = (() => {
- async function init(container) {
- let settings, notifPrefs;
- try {
- [settings, notifPrefs] = await Promise.all([
- API.settings(),
- fetch('/api/notifications/me').then(r => r.ok ? r.json() : null),
- ]);
- } catch (e) {
- container.innerHTML = `
`;
- return;
- }
-
- const notifSection = buildNotifSection(notifPrefs);
-
- container.innerHTML = `
-
-
-
-
General
-
- Currency
-
- USD $
- EUR β¬
- GBP Β£
- CAD $
-
-
-
- Date Format
-
- MM/DD/YYYY
- DD/MM/YYYY
- YYYY-MM-DD
-
-
-
-
-
-
Billing Behavior
-
- Grace Period (days)
-
-
-
-
-
-
- ${notifSection}
-
-
- Save Settings
-
- `;
-
- document.getElementById('save-settings-btn').onclick = async () => {
- const data = {
- currency: document.getElementById('s-currency').value,
- date_format: document.getElementById('s-date-format').value,
- grace_period_days: document.getElementById('s-grace').value,
- backup_enabled: document.getElementById('s-backup-enabled').checked ? 'true' : 'false',
- backup_frequency_days: document.getElementById('s-backup-freq').value,
- backup_keep_count: document.getElementById('s-backup-keep').value,
- };
- try {
- await API.saveSettings(data);
- showToast('Settings saved', 'success');
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- }
- };
-
- // Notification save (only wired if section exists)
- const notifForm = document.getElementById('notif-user-form');
- if (notifForm) {
- notifForm.onsubmit = async (e) => {
- e.preventDefault();
- const btn = notifForm.querySelector('[type="submit"]');
- btn.disabled = true;
- try {
- const res = await fetch('/api/notifications/me', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- notification_email: document.getElementById('n-email').value.trim(),
- notifications_enabled:document.getElementById('n-enabled').checked,
- notify_3d: document.getElementById('n-3d').checked,
- notify_1d: document.getElementById('n-1d').checked,
- notify_due: document.getElementById('n-due').checked,
- notify_overdue: document.getElementById('n-overdue').checked,
- }),
- });
- if (!res.ok) throw new Error((await res.json()).error);
- showToast('Notification preferences saved', 'success');
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- } finally {
- btn.disabled = false;
- }
- };
-
- // Toggle field visibility based on enabled checkbox
- const toggle = () => {
- const on = document.getElementById('n-enabled').checked;
- document.getElementById('n-options').style.opacity = on ? '1' : '.4';
- document.getElementById('n-options').style.pointerEvents = on ? '' : 'none';
- };
- document.getElementById('n-enabled').addEventListener('change', toggle);
- toggle();
- }
- }
-
- function buildNotifSection(p) {
- if (!p) return ''; // API call failed, skip silently
-
- if (!p.smtp_enabled) {
- return `
-
-
Notifications
-
- Email notifications have not been configured by the admin.
-
-
`;
- }
-
- if (!p.allow_user_config) {
- return `
-
-
Notifications
-
- Email notifications are enabled and managed by the admin.
- Your bills will generate reminders automatically.
-
-
`;
- }
-
- // Full user-configurable notification section
- const chk = (id, label, val) =>
- `
- ${label}
- `;
-
- return `
-
-
Notifications
-
-
- Enable Notifications
-
-
- Send me email reminders
-
-
-
-
-
- Notification Email
-
-
-
-
-
Remind me
-
- ${chk('n-3d', '3 days before due', p.notify_3d)}
- ${chk('n-1d', '1 day before due', p.notify_1d)}
- ${chk('n-due', 'On the day it\'s due', p.notify_due)}
- ${chk('n-overdue', 'Daily while overdue', p.notify_overdue)}
-
-
-
-
-
- Save Notifications
-
-
-
`;
- }
-
- function escHtml(s) {
- return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
- }
-
- return { init };
-})();
diff --git a/legacy/js/status.js b/legacy/js/status.js
deleted file mode 100644
index 157591f..0000000
--- a/legacy/js/status.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/* ββ Status page ββ */
-
-const StatusPage = (() => {
- async function init(container) {
- container.innerHTML = `
-
-
- `;
- document.getElementById('status-refresh').onclick = () => load(container);
- load(container);
- }
-
- async function load(container) {
- const body = document.getElementById('status-body');
- try {
- const res = await fetch('/api/status');
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const d = await res.json();
- body.innerHTML = render(d);
- } catch (e) {
- body.innerHTML = `
Failed to load status: ${e.message}
`;
- }
- }
-
- function fmtUptime(seconds) {
- const d = Math.floor(seconds / 86400);
- const h = Math.floor((seconds % 86400) / 3600);
- const m = Math.floor((seconds % 3600) / 60);
- const s = seconds % 60;
- if (d > 0) return `${d}d ${h}h ${m}m`;
- if (h > 0) return `${h}h ${m}m ${s}s`;
- if (m > 0) return `${m}m ${s}s`;
- return `${s}s`;
- }
-
- function fmtBytes(bytes) {
- if (bytes === 0) return '0 B';
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
- return `${(bytes / 1048576).toFixed(2)} MB`;
- }
-
- function row(label, value, note = '') {
- return `
-
- ${label}
- ${value}${note ? `${note} ` : ''}
-
`;
- }
-
- function render(d) {
- const dbOk = d.database.status === 'connected';
- return `
-
-
-
-
- ${row('Version', `v${d.app.version}`)}
- ${row('Environment', d.app.environment)}
- ${row('Uptime', fmtUptime(d.app.uptime_seconds))}
-
-
-
-
- ${row('Node.js', d.runtime.node_version)}
- ${row('Platform', `${d.runtime.platform} / ${d.runtime.arch}`)}
- ${row('Memory', `${d.runtime.memory_mb} MB`)}
-
-
-
-
- ${row('Status', dbOk ? 'Connected' : 'Error')}
- ${row('Size', fmtBytes(d.database.size_bytes))}
- ${row('File', `${d.database.path}`)}
-
-
-
-
- ${row('Active Bills', d.stats.active_bills)}
- ${row('Total Payments', d.stats.total_payments)}
- ${row('Users', d.stats.users)}
- ${row('Active Sessions', d.stats.active_sessions)}
-
-
-
- `;
- }
-
- return { init };
-})();
diff --git a/legacy/js/tracker.js b/legacy/js/tracker.js
deleted file mode 100644
index 4417441..0000000
--- a/legacy/js/tracker.js
+++ /dev/null
@@ -1,424 +0,0 @@
-/* ββ Tracker page ββ */
-
-const TrackerPage = (() => {
- let currentYear, currentMonth;
- let trackerData = null;
-
- const MONTH_NAMES = [
- 'January','February','March','April','May','June',
- 'July','August','September','October','November','December',
- ];
-
- const STATUS_META = {
- paid: { label: 'Paid', cls: 'badge-paid' },
- upcoming: { label: 'Upcoming', cls: 'badge-upcoming' },
- due_soon: { label: 'Due Soon', cls: 'badge-due-soon' },
- late: { label: 'Late', cls: 'badge-late' },
- missed: { label: 'Missed', cls: 'badge-missed' },
- autodraft: { label: 'Autodraft', cls: 'badge-autodraft' },
- };
-
- function fmt(amount) {
- return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
- }
-
- function fmtDate(dateStr) {
- if (!dateStr) return '';
- const [y, m, d] = dateStr.split('-');
- return `${parseInt(m)}/${parseInt(d)}/${y}`;
- }
-
- function todayStr() {
- return new Date().toISOString().slice(0, 10);
- }
-
- function init(container) {
- const now = new Date();
- currentYear = now.getFullYear();
- currentMonth = now.getMonth() + 1;
- render(container);
- }
-
- function render(container) {
- container.innerHTML = `
-
-
-
- `;
-
- document.getElementById('month-label').textContent =
- `${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
-
- document.getElementById('prev-month').onclick = () => navigate(-1, container);
- document.getElementById('next-month').onclick = () => navigate(1, container);
- document.getElementById('today-btn').onclick = () => {
- const now = new Date();
- currentYear = now.getFullYear();
- currentMonth = now.getMonth() + 1;
- loadData(container);
- };
-
- loadData(container);
- }
-
- function navigate(delta, container) {
- currentMonth += delta;
- if (currentMonth > 12) { currentMonth = 1; currentYear++; }
- if (currentMonth < 1) { currentMonth = 12; currentYear--; }
- document.getElementById('month-label').textContent =
- `${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
- loadData(container);
- }
-
- async function loadData(container) {
- document.getElementById('tracker-body').innerHTML = '
Loading...
';
- try {
- trackerData = await API.tracker(currentYear, currentMonth);
- renderSummary(trackerData.summary);
- renderRows(trackerData.rows, container);
- } catch (e) {
- document.getElementById('tracker-body').innerHTML =
- `
Failed to load tracker: ${e.message}
`;
- }
- }
-
- function renderSummary(s) {
- document.getElementById('summary-bar').innerHTML = `
-
-
Total Expected
-
${fmt(s.total_expected)}
-
-
-
Total Paid
-
${fmt(s.total_paid)}
-
-
-
Remaining
-
${fmt(s.remaining)}
-
-
-
Overdue
-
${fmt(s.overdue)}
-
- `;
- }
-
- function renderRows(rows, container) {
- const body = document.getElementById('tracker-body');
- if (!rows || rows.length === 0) {
- body.innerHTML = `
`;
- return;
- }
-
- const first = rows.filter(r => r.bucket === '1st');
- const second = rows.filter(r => r.bucket === '15th');
-
- body.innerHTML = '';
- if (first.length) body.appendChild(renderBucket('1stβ14th', first));
- if (second.length) body.appendChild(renderBucket('15thβ31st', second));
-
- // Attach event listeners after render
- attachTableListeners(container);
- }
-
- function renderBucket(label, rows) {
- const totalExpected = rows.reduce((s, r) => s + r.expected_amount, 0);
- const totalPaid = rows.reduce((s, r) => s + r.total_paid, 0);
-
- const section = document.createElement('div');
- section.className = 'bucket-section';
- section.innerHTML = `
-
-
-
-
- Bill
- Due
- Expected
- Amount Paid
- Paid Date
- Status
-
-
-
-
- ${rows.map(renderRow).join('')}
-
-
- `;
- return section;
- }
-
- function renderRow(row) {
- const meta = STATUS_META[row.status] || STATUS_META.upcoming;
- const rowCls = `row-${row.status}`;
-
- const paidDate = row.last_paid_date ? fmtDate(row.last_paid_date) : '';
- const paidAmt = row.total_paid > 0 ? fmt(row.total_paid) : '';
- const mismatch = row.total_paid > 0 && row.total_paid !== row.expected_amount;
- const isPaid = row.status === 'paid' || row.status === 'autodraft';
-
- const autopayDot = row.autopay_enabled
- ? `
`
- : '';
-
- return `
-
-
-
- ${autopayDot}
-
-
${escHtml(row.name)}
- ${row.category_name ? `
${escHtml(row.category_name)}
` : ''}
-
-
-
-
- ${fmtDate(row.due_date)}
-
-
- ${fmt(row.expected_amount)}
-
-
-
-
- ${paidAmt || 'β'}
-
-
-
-
-
-
- ${paidDate || 'β'}
-
-
-
-
-
- ${meta.label}
-
-
-
-
- ${!isPaid ? `
-
-
- Pay
-
` : ''}
- ${row.payments && row.payments.length > 0
- ? `
✎ `
- : ''}
-
-
-
- `;
- }
-
- function attachTableListeners(container) {
- // Quick pay buttons β read amount from the sibling input
- container.querySelectorAll('.btn-quick-pay').forEach(btn => {
- btn.onclick = async (e) => {
- e.stopPropagation();
- const billId = btn.dataset.billId;
- const amtInput = btn.closest('.quick-pay-group')?.querySelector('.quick-pay-amount');
- const amount = amtInput ? parseFloat(amtInput.value) || 0 : 0;
- if (amount <= 0) { showToast('Enter a payment amount', 'error'); return; }
- btn.disabled = true;
- try {
- await API.quickPay({ bill_id: billId, amount, paid_date: todayStr() });
- showToast('Marked as paid', 'success');
- loadData(container);
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- btn.disabled = false;
- }
- };
- });
-
- // Edit payment buttons
- container.querySelectorAll('.btn-edit-payment').forEach(btn => {
- btn.onclick = (e) => {
- e.stopPropagation();
- const payment = JSON.parse(btn.dataset.payment);
- openPaymentModal(payment, () => loadData(container));
- };
- });
-
- // Inline editable amount cells
- container.querySelectorAll('.amount-cell').forEach(cell => {
- cell.onclick = () => startInlineEdit(cell, 'number', container);
- });
-
- // Inline editable date cells
- container.querySelectorAll('.date-cell').forEach(cell => {
- cell.onclick = () => startInlineEdit(cell, 'date', container);
- });
- }
-
- function startInlineEdit(cell, type, container) {
- if (cell.querySelector('input')) return; // already editing
-
- const billId = cell.dataset.billId;
- const field = cell.dataset.field;
- const row = trackerData?.rows?.find(r => r.id == billId);
- if (!row) return;
-
- let currentVal = '';
- if (field === 'amount') currentVal = row.total_paid > 0 ? String(row.total_paid) : '';
- if (field === 'date') currentVal = row.last_paid_date || '';
-
- const input = document.createElement('input');
- input.type = type === 'date' ? 'date' : 'number';
- if (type === 'number') { input.step = '0.01'; input.min = '0'; }
- input.value = currentVal;
- input.style.cssText = 'width:100%;min-width:80px;';
-
- const origText = cell.textContent.trim();
- cell.textContent = '';
- cell.appendChild(input);
- cell.classList.remove('empty');
- input.focus();
- input.select();
-
- async function commit() {
- const val = input.value.trim();
- if (!val) { cell.textContent = origText || 'β'; cell.classList.add('empty'); return; }
-
- try {
- if (row.payments && row.payments.length > 0) {
- const p = row.payments[0];
- const update = {};
- if (field === 'amount') update.amount = parseFloat(val);
- if (field === 'date') update.paid_date = val;
- await API.updatePayment(p.id, update);
- } else {
- // Create new payment
- const paidDate = field === 'date' ? val : todayStr();
- const amount = field === 'amount' ? parseFloat(val) : row.expected_amount;
- await API.createPayment({ bill_id: billId, amount, paid_date: paidDate });
- }
- showToast('Saved', 'success');
- loadData(container);
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- cell.textContent = origText || 'β';
- }
- }
-
- input.addEventListener('blur', commit);
- input.addEventListener('keydown', e => {
- if (e.key === 'Enter') input.blur();
- if (e.key === 'Escape') {
- cell.textContent = origText || 'β';
- if (!origText) cell.classList.add('empty');
- }
- });
- }
-
-function openPaymentModal(payment, onSave) {
- const modal = document.getElementById('payment-modal');
-
- document.getElementById('payment-bill-id').value = payment.bill_id;
- document.getElementById('payment-id').value = payment.id;
- document.getElementById('payment-amount').value = payment.amount;
- document.getElementById('payment-date').value = payment.paid_date;
- document.getElementById('payment-method').value = payment.method || '';
- document.getElementById('payment-notes').value = payment.notes || '';
-
- document.getElementById('payment-modal-title').textContent = 'Edit Payment';
-
- modal.classList.remove('hidden');
-
- const close = () => modal.classList.add('hidden');
-
- document.getElementById('payment-modal-close').onclick = close;
- document.getElementById('payment-modal-cancel').onclick = close;
- modal.querySelector('.modal-overlay').onclick = close;
-
- // β
UPDATED DELETE LOGIC WITH CLEAR INTENT
- document.getElementById('payment-delete').onclick = async () => {
- const confirmDelete = confirm(
- 'Remove this payment?\n\n' +
- '- The BILL will NOT be deleted\n' +
- '- This will remove the payment record\n' +
- '- The bill will become UNPAID\n\n' +
- 'Continue?'
- );
-
- if (!confirmDelete) return;
-
- try {
- await API.deletePayment(payment.id);
-
- close();
-
- showToast(
- 'Payment removed. Bill is now marked as unpaid.',
- 'success'
- );
-
- onSave(); // refresh tracker
-
- } catch (e) {
- showToast('Error: ' + e.message, 'error');
- }
- };
-
- // normal save logic untouched
- document.getElementById('payment-form').onsubmit = async (e) => {
- e.preventDefault();
-
- const data = {
- amount: parseFloat(document.getElementById('payment-amount').value),
- paid_date: document.getElementById('payment-date').value,
- method: document.getElementById('payment-method').value || null,
- notes: document.getElementById('payment-notes').value || null,
- };
-
- try {
- await API.updatePayment(payment.id, data);
-
- close();
- showToast('Payment saved', 'success');
- onSave();
-
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- }
- };
-}
-
- function escHtml(str) {
- return String(str || '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
- }
-
- return { init };
-})();
diff --git a/legacy/login.html b/legacy/login.html
deleted file mode 100644
index 19a9965..0000000
--- a/legacy/login.html
+++ /dev/null
@@ -1,174 +0,0 @@
-
-
-
-
-
-
Bill Tracker β Sign In
-
-
-
-
-
-
Sign in to your account
-
-
-
- Username
-
-
-
- Password
-
-
- Sign In
-
-
-
-
-
-
diff --git a/public/admin.html b/public/admin.html
deleted file mode 100644
index 163d0f2..0000000
--- a/public/admin.html
+++ /dev/null
@@ -1,766 +0,0 @@
-
-
-
-
-
-
Bill Tracker β Admin
-
-
-
-
-
-
-
$
-
BillTracker
-
Admin
-
-
-
- Sign Out
-
-
-
-
-
-
-
-
-
-
-
🔒
-
Welcome to Bill Tracker
-
- You're logged in as the admin . Before you get started,
- here's exactly what this account can and cannot do.
-
-
-
- ✓
-
- Create user accounts
- You control who can log in.
-
-
-
- ✓
-
- Reset user passwords
- If a user gets locked out, you can reset their password.
-
-
-
- ✗
-
- Cannot access any financial data
- Bills, payments, and tracker data are completely off-limits to this account β by design.
-
-
-
- ✗
-
- Cannot view account balances or history
- Your financial privacy is enforced at the API level, not just the UI.
-
-
-
-
- Got it β create my user account →
-
-
-
-
-
-
-
-
👤
-
Create Your User Account
-
- This account will have full access to the tracker, bills, and payments.
- Use this account for your day-to-day bill tracking.
-
-
-
-
-
- Create Account & Go to Admin Panel
-
-
- ← Back
-
-
-
-
-
-
-
-
-
-
-
-
Admin Account Scope
-
- You can create user accounts and reset passwords.
- You cannot view, access, or modify any bills, payments, or financial data.
- Users are informed that only their password can be reset by this account.
-
-
-
-
-
Email Notifications
-
-
-
-
-
-
-
Add User
-
-
- Username
-
-
-
- Password
-
-
- Create User
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/css/style.css b/public/css/style.css
deleted file mode 100644
index 833c2c6..0000000
--- a/public/css/style.css
+++ /dev/null
@@ -1,888 +0,0 @@
-/* ββ Reset & base ββ */
-*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
-
-:root {
- --bg: #0d1526;
- --surface: #162236;
- --surface-2: #1c2d47;
- --surface-3: #223558;
- --border: rgba(255,255,255,0.07);
- --border-strong: rgba(255,255,255,0.14);
- --text: #e2e8f0;
- --text-muted: #8faab8;
- --text-faint: #506070;
- --primary: #6366f1;
- --primary-hover: #4f46e5;
- --primary-light: rgba(99,102,241,0.18);
- --success: #22d3a5;
- --success-light: rgba(34,211,165,0.15);
- --danger: #f43f5e;
- --danger-light: rgba(244,63,94,0.15);
- --warning: #fb923c;
- --warning-light: rgba(251,146,60,0.15);
- --info: #38bdf8;
- --info-light: rgba(56,189,248,0.15);
- --sidebar-w: 200px;
- --header-h: 56px;
- --radius: 6px;
- --shadow: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px var(--border);
- --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-}
-
-body {
- font-family: var(--font);
- font-size: 14px;
- background: var(--bg);
- color: var(--text);
- line-height: 1.5;
- min-height: 100vh;
-}
-
-/* ββ Scrollbar ββ */
-::-webkit-scrollbar { width: 6px; height: 6px; }
-::-webkit-scrollbar-track { background: transparent; }
-::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 3px; }
-::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
-
-/* ββ Layout ββ */
-#app {
- display: flex;
- min-height: 100vh;
-}
-
-.sidebar {
- width: var(--sidebar-w);
- background: #0a1120;
- color: var(--text-muted);
- display: flex;
- flex-direction: column;
- flex-shrink: 0;
- position: fixed;
- top: 0; left: 0; bottom: 0;
- z-index: 100;
- border-right: 1px solid var(--border);
-}
-
-.logo {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 20px 16px 16px;
- border-bottom: 1px solid var(--border);
- margin-bottom: 8px;
-}
-
-.logo-icon {
- width: 30px; height: 30px;
- background: var(--primary);
- border-radius: var(--radius);
- display: flex; align-items: center; justify-content: center;
- font-weight: 800; font-size: 15px; color: white;
- box-shadow: 0 0 12px rgba(99,102,241,0.35);
-}
-
-.logo-text {
- font-weight: 600;
- color: var(--text);
- font-size: 15px;
-}
-
-.nav-links {
- list-style: none;
- padding: 0 8px;
-}
-
-.nav-links li { margin: 2px 0; }
-
-.nav-link {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 8px 10px;
- border-radius: var(--radius);
- color: var(--text-muted);
- text-decoration: none;
- font-size: 13.5px;
- transition: background .15s, color .15s;
-}
-
-.nav-link:hover { background: var(--surface-2); color: var(--text); }
-.nav-link.active { background: var(--primary-light); color: var(--primary); }
-
-.nav-icon { font-size: 12px; opacity: .8; }
-
-.sidebar-footer {
- margin-top: auto;
- padding: 8px 8px 12px;
- border-top: 1px solid var(--border);
-}
-
-#sidebar-username {
- display: block;
- font-size: 11px;
- color: var(--text-faint);
- padding: 4px 10px 6px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.sidebar-logout {
- font-size: 12px !important;
- opacity: .7;
-}
-.sidebar-logout:hover { opacity: 1; }
-
-.main-content {
- margin-left: var(--sidebar-w);
- flex: 1;
- min-height: 100vh;
- padding: 24px;
-}
-
-.page { display: none; }
-.page.active { display: block; }
-
-/* ββ Page header ββ */
-.page-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 20px;
-}
-
-.page-title {
- font-size: 20px;
- font-weight: 700;
- color: var(--text);
-}
-
-/* ββ Summary cards ββ */
-.summary-bar {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 12px;
- margin-bottom: 20px;
-}
-
-.summary-card {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 14px 16px;
- box-shadow: var(--shadow);
- position: relative;
- overflow: hidden;
-}
-
-.summary-card::before {
- content: '';
- position: absolute;
- top: 0; left: 0; right: 0;
- height: 2px;
- background: var(--border-strong);
-}
-
-.summary-card.danger::before { background: var(--danger); }
-.summary-card.success::before { background: var(--success); }
-.summary-card.warning::before { background: var(--warning); }
-
-.summary-card .label {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: .06em;
- color: var(--text-muted);
- margin-bottom: 6px;
-}
-
-.summary-card .value {
- font-size: 22px;
- font-weight: 700;
- color: var(--text);
-}
-
-.summary-card.danger .value { color: var(--danger); }
-.summary-card.success .value { color: var(--success); }
-.summary-card.warning .value { color: var(--warning); }
-
-/* ββ Month nav ββ */
-.month-nav {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.month-nav .month-label {
- font-size: 16px;
- font-weight: 600;
- min-width: 130px;
- text-align: center;
- color: var(--text);
-}
-
-/* ββ Tracker table ββ */
-.bucket-section {
- margin-bottom: 24px;
-}
-
-.bucket-header {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 6px 0;
- margin-bottom: 8px;
- border-bottom: 2px solid var(--border);
-}
-
-.bucket-label {
- font-size: 12px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: .07em;
- color: var(--text-muted);
-}
-
-.bucket-totals {
- font-size: 12px;
- color: var(--text-faint);
- margin-left: auto;
-}
-
-.tracker-table {
- width: 100%;
- border-collapse: collapse;
- background: var(--surface);
- border-radius: var(--radius);
- overflow: hidden;
- box-shadow: var(--shadow);
- border: 1px solid var(--border);
-}
-
-.tracker-table th {
- background: var(--surface-2);
- padding: 9px 12px;
- text-align: left;
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: .06em;
- color: var(--text-muted);
- border-bottom: 1px solid var(--border-strong);
- white-space: nowrap;
-}
-
-.tracker-table td {
- padding: 0;
- border-bottom: 1px solid var(--border);
- vertical-align: middle;
-}
-
-.tracker-table tr:last-child td { border-bottom: none; }
-
-.tracker-table tr:hover td { background: var(--surface-2); }
-.tracker-table tr.row-paid:hover td { background: rgba(34,211,165,0.07); }
-.tracker-table tr.row-late:hover td { background: rgba(251,146,60,0.07); }
-.tracker-table tr.row-missed:hover td { background: rgba(244,63,94,0.07); }
-
-.tracker-table tr.row-paid td { background: rgba(34,211,165,0.04); }
-.tracker-table tr.row-late td { background: rgba(251,146,60,0.04); }
-.tracker-table tr.row-missed td { background: rgba(244,63,94,0.04); }
-.tracker-table tr.row-autodraft td { background: rgba(251,146,60,0.04); }
-
-.td-inner {
- padding: 10px 12px;
- display: flex;
- align-items: center;
- gap: 6px;
- min-height: 44px;
-}
-
-/* ββ Status badge ββ */
-.badge {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 3px 8px;
- border-radius: 20px;
- font-size: 11px;
- font-weight: 600;
- white-space: nowrap;
- letter-spacing: .02em;
-}
-
-.badge-paid { background: var(--success-light); color: var(--success); }
-.badge-upcoming { background: var(--surface-3); color: var(--text-muted); }
-.badge-due-soon { background: var(--warning-light); color: var(--warning); }
-.badge-late { background: var(--warning-light); color: var(--warning); }
-.badge-missed { background: var(--danger-light); color: var(--danger); }
-.badge-autodraft { background: var(--warning-light); color: var(--warning); }
-
-/* ββ Inline editable cells ββ */
-.editable-cell {
- cursor: pointer;
- border-radius: 4px;
- padding: 4px 6px;
- min-width: 80px;
- transition: background .1s;
-}
-
-.editable-cell:hover { background: var(--primary-light); }
-.editable-cell.empty { color: var(--text-faint); }
-
-.editable-cell input {
- border: none;
- outline: none;
- background: transparent;
- font-size: inherit;
- font-family: inherit;
- color: var(--text);
- width: 100%;
- min-width: 80px;
-}
-
-/* ββ Bill name cell ββ */
-.bill-name-cell {
- font-weight: 500;
- color: var(--text);
-}
-.bill-category {
- font-size: 11px;
- color: var(--text-faint);
-}
-
-/* ββ Quick pay group ββ */
-.quick-pay-group {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: 6px;
-}
-
-.quick-pay-group input[type="number"] {
- width: 80px;
- padding: 4px 8px;
- font-size: 12px;
-}
-
-/* ββ Action buttons ββ */
-.btn {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 6px 12px;
- border: none;
- border-radius: var(--radius);
- font-size: 13px;
- font-family: var(--font);
- font-weight: 500;
- cursor: pointer;
- transition: background .15s, color .15s, opacity .15s, box-shadow .15s;
- white-space: nowrap;
-}
-
-.btn:disabled { opacity: .4; cursor: not-allowed; }
-
-.btn-primary { background: var(--primary); color: white; }
-.btn-primary:hover:not(:disabled) { background: var(--primary-hover); box-shadow: 0 0 0 3px rgba(99,102,241,0.25); }
-
-.btn-success { background: var(--success); color: #0d1526; }
-.btn-success:hover:not(:disabled) { background: #1ab890; }
-
-.btn-ghost {
- background: transparent;
- color: var(--text-muted);
- border: 1px solid var(--border-strong);
-}
-.btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); }
-
-.btn-danger { background: var(--danger); color: white; }
-.btn-danger:hover:not(:disabled) { background: #e11d48; }
-
-.btn-sm { padding: 4px 8px; font-size: 12px; }
-
-.btn-pay {
- background: var(--primary-light);
- color: var(--primary);
- border: 1px solid rgba(99,102,241,0.3);
- padding: 4px 10px;
- font-size: 12px;
- font-weight: 600;
- border-radius: var(--radius);
-}
-.btn-pay:hover { background: var(--primary); color: white; border-color: var(--primary); }
-
-.btn-icon {
- background: transparent;
- color: var(--text-faint);
- border: none;
- padding: 4px 6px;
- font-size: 14px;
- border-radius: 4px;
- cursor: pointer;
- transition: background .15s, color .15s;
-}
-.btn-icon:hover { background: var(--surface-2); color: var(--text); }
-
-/* ββ Action cell ββ */
-.action-cell {
- display: flex;
- align-items: center;
- gap: 4px;
- padding: 8px 10px;
-}
-
-/* ββ Amount display ββ */
-.amount-expected { color: var(--text-muted); font-size: 13px; }
-.amount-actual { font-weight: 600; color: var(--text); }
-.amount-mismatch { color: var(--warning); }
-
-/* ββ Bills management page ββ */
-.bills-grid {
- display: grid;
- gap: 10px;
-}
-
-.bill-card {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 14px 16px;
- display: flex;
- align-items: center;
- gap: 12px;
- box-shadow: var(--shadow);
- transition: border-color .15s, background .15s;
-}
-
-.bill-card:hover { border-color: var(--primary); background: var(--surface-2); }
-.bill-card.inactive { opacity: .45; }
-
-.bill-card-info { flex: 1; min-width: 0; }
-.bill-card-name { font-weight: 600; font-size: 14px; color: var(--text); }
-.bill-card-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
-
-.bill-card-amount {
- font-size: 16px;
- font-weight: 700;
- color: var(--text);
- min-width: 80px;
- text-align: right;
-}
-
-.bill-card-actions { display: flex; gap: 6px; }
-
-/* ββ Categories page ββ */
-.cat-list { display: flex; flex-direction: column; gap: 8px; }
-
-.cat-item {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 10px 14px;
- display: flex;
- align-items: center;
- gap: 10px;
- box-shadow: var(--shadow);
- transition: border-color .15s;
-}
-.cat-item:hover { border-color: var(--border-strong); }
-
-.cat-name { flex: 1; font-weight: 500; color: var(--text); }
-
-.cat-add-form {
- display: flex;
- gap: 8px;
- margin-bottom: 16px;
-}
-
-/* ββ Settings page ββ */
-.settings-section {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 20px;
- margin-bottom: 16px;
- box-shadow: var(--shadow);
-}
-
-.settings-section h3 {
- font-size: 14px;
- font-weight: 700;
- margin-bottom: 16px;
- color: var(--text);
- border-bottom: 1px solid var(--border);
- padding-bottom: 8px;
-}
-
-.settings-row {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 12px;
-}
-
-.settings-row:last-child { margin-bottom: 0; }
-
-.settings-row label {
- min-width: 180px;
- font-size: 13px;
- color: var(--text-muted);
- font-weight: 500;
-}
-
-/* ββ Forms ββ */
-.form-group {
- display: flex;
- flex-direction: column;
- gap: 5px;
-}
-
-.form-group label {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-muted);
- text-transform: uppercase;
- letter-spacing: .04em;
-}
-
-.form-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 14px;
- margin-bottom: 16px;
-}
-
-.form-group.full-width { grid-column: 1 / -1; }
-.form-group.checkbox-group { flex-direction: row; flex-wrap: wrap; gap: 16px; }
-.form-group.checkbox-group label {
- display: flex; align-items: center; gap: 6px;
- text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 500; color: var(--text);
- cursor: pointer;
-}
-
-input[type="text"],
-input[type="number"],
-input[type="date"],
-input[type="email"],
-input[type="password"],
-select,
-textarea {
- padding: 7px 10px;
- border: 1px solid var(--border-strong);
- border-radius: var(--radius);
- font-size: 13px;
- font-family: var(--font);
- color: var(--text);
- background: var(--surface-2);
- transition: border-color .15s, box-shadow .15s;
- width: 100%;
-}
-
-input:focus, select:focus, textarea:focus {
- outline: none;
- border-color: var(--primary);
- box-shadow: 0 0 0 3px rgba(99,102,241,0.2);
-}
-
-select option {
- background: var(--surface-2);
- color: var(--text);
-}
-
-input::placeholder { color: var(--text-faint); }
-textarea::placeholder { color: var(--text-faint); }
-textarea { resize: vertical; }
-
-input[type="checkbox"] {
- width: 15px;
- height: 15px;
- accent-color: var(--primary);
- cursor: pointer;
-}
-
-/* ββ Modal ββ */
-.modal {
- position: fixed;
- inset: 0;
- z-index: 200;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.modal.hidden { display: none; }
-
-.modal-overlay {
- position: absolute;
- inset: 0;
- background: rgba(0,0,0,0.65);
- backdrop-filter: blur(2px);
-}
-
-.modal-box {
- position: relative;
- background: var(--surface);
- border: 1px solid var(--border-strong);
- border-radius: 10px;
- width: 100%;
- max-width: 560px;
- max-height: 90vh;
- overflow-y: auto;
- box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px var(--border);
- padding: 24px;
-}
-
-.modal-box-sm { max-width: 380px; }
-
-.modal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 20px;
-}
-
-.modal-header h2 {
- font-size: 16px;
- font-weight: 700;
- color: var(--text);
-}
-
-.modal-close {
- background: none;
- border: none;
- font-size: 22px;
- color: var(--text-faint);
- cursor: pointer;
- padding: 0 4px;
- line-height: 1;
- transition: color .15s;
-}
-.modal-close:hover { color: var(--text); }
-
-.modal-footer {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: 8px;
- margin-top: 20px;
- padding-top: 16px;
- border-top: 1px solid var(--border);
-}
-
-/* ββ Toast notifications ββ */
-#toast-container {
- position: fixed;
- bottom: 24px;
- right: 24px;
- z-index: 999;
- display: flex;
- flex-direction: column-reverse;
- gap: 8px;
- pointer-events: none;
-}
-
-.toast {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- min-width: 280px;
- max-width: 380px;
- padding: 12px 14px 12px 16px;
- border-radius: var(--radius);
- background: var(--surface-2);
- border: 1px solid var(--border-strong);
- border-left-width: 3px;
- box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04);
- font-size: 13px;
- font-weight: 500;
- color: var(--text);
- opacity: 0;
- transform: translateX(20px) translateY(4px);
- transition: opacity .25s ease, transform .25s ease;
- pointer-events: auto;
-}
-
-.toast.show {
- opacity: 1;
- transform: translateX(0) translateY(0);
-}
-
-.toast.hide {
- opacity: 0;
- transform: translateX(20px) translateY(4px);
- transition: opacity .2s ease, transform .2s ease;
-}
-
-.toast-icon {
- font-size: 15px;
- flex-shrink: 0;
- margin-top: 1px;
-}
-
-.toast-body { flex: 1; line-height: 1.4; }
-
-.toast.success { border-left-color: var(--success); }
-.toast.success .toast-icon { color: var(--success); }
-
-.toast.error { border-left-color: var(--danger); }
-.toast.error .toast-icon { color: var(--danger); }
-
-.toast.warning { border-left-color: var(--warning); }
-.toast.warning .toast-icon { color: var(--warning); }
-
-.toast.info { border-left-color: var(--info); }
-.toast.info .toast-icon { color: var(--info); }
-
-/* Legacy single #toast element (backwards compatibility) */
-#toast {
- position: fixed;
- bottom: 24px;
- right: 24px;
- background: var(--surface-2);
- color: var(--text);
- padding: 11px 16px 11px 18px;
- border-radius: var(--radius);
- border: 1px solid var(--border-strong);
- border-left: 3px solid var(--primary);
- font-size: 13px;
- font-weight: 500;
- box-shadow: 0 8px 32px rgba(0,0,0,0.5);
- z-index: 999;
- opacity: 0;
- transform: translateX(20px);
- transition: opacity .25s, transform .25s;
- pointer-events: none;
- max-width: 360px;
-}
-
-#toast.show { opacity: 1; transform: translateX(0); }
-#toast.success { border-left-color: var(--success); }
-#toast.error { border-left-color: var(--danger); }
-#toast.warning { border-left-color: var(--warning); }
-
-/* ββ Status page ββ */
-.status-page {
- max-width: 700px;
- margin: 0 auto;
-}
-
-.status-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
- gap: 14px;
- margin-bottom: 24px;
-}
-
-.status-card {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 16px 18px;
- box-shadow: var(--shadow);
- display: flex;
- align-items: center;
- gap: 14px;
-}
-
-.status-dot {
- width: 11px;
- height: 11px;
- border-radius: 50%;
- flex-shrink: 0;
- position: relative;
-}
-
-.status-dot.green {
- background: var(--success);
- box-shadow: 0 0 0 0 rgba(34,211,165,0.4);
- animation: pulse-green 2s infinite;
-}
-
-.status-dot.red {
- background: var(--danger);
- box-shadow: 0 0 0 0 rgba(244,63,94,0.4);
- animation: pulse-red 2s infinite;
-}
-
-@keyframes pulse-green {
- 0% { box-shadow: 0 0 0 0 rgba(34,211,165,0.5); }
- 70% { box-shadow: 0 0 0 7px rgba(34,211,165,0); }
- 100% { box-shadow: 0 0 0 0 rgba(34,211,165,0); }
-}
-
-@keyframes pulse-red {
- 0% { box-shadow: 0 0 0 0 rgba(244,63,94,0.5); }
- 70% { box-shadow: 0 0 0 7px rgba(244,63,94,0); }
- 100% { box-shadow: 0 0 0 0 rgba(244,63,94,0); }
-}
-
-.stat-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 9px 0;
- border-bottom: 1px solid var(--border);
- font-size: 13px;
-}
-
-.stat-row:last-child { border-bottom: none; }
-
-.stat-row .stat-label { color: var(--text-muted); }
-.stat-row .stat-value { font-weight: 600; color: var(--text); }
-
-/* ββ Misc ββ */
-.empty-state {
- text-align: center;
- padding: 48px 20px;
- color: var(--text-faint);
-}
-.empty-state p { font-size: 14px; margin-top: 8px; }
-
-.loading {
- text-align: center;
- padding: 32px;
- color: var(--text-faint);
- font-size: 13px;
-}
-
-.autopay-dot {
- display: inline-block;
- width: 6px; height: 6px;
- border-radius: 50%;
- background: var(--warning);
- margin-right: 2px;
- vertical-align: middle;
-}
-
-.text-muted { color: var(--text-muted); }
-.text-faint { color: var(--text-faint); }
-.text-sm { font-size: 12px; }
-.mt-1 { margin-top: 4px; }
-.gap-8 { gap: 8px; }
-
-/* ββ Divider ββ */
-hr {
- border: none;
- border-top: 1px solid var(--border);
- margin: 12px 0;
-}
-
-/* ββ Code / monospace ββ */
-code {
- background: var(--surface-3);
- color: var(--info);
- padding: 1px 5px;
- border-radius: 3px;
- font-size: 12px;
- font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
-}
-
-/* ββ Responsive ββ */
-@media (max-width: 768px) {
- .sidebar { width: 60px; }
- .logo-text, .nav-link span:not(.nav-icon) { display: none; }
- .main-content { margin-left: 60px; padding: 16px; }
- .summary-bar { grid-template-columns: repeat(2, 1fr); }
- .form-grid { grid-template-columns: 1fr; }
-}
diff --git a/public/img/logo.png b/public/img/logo.png
deleted file mode 100644
index 6885b04..0000000
Binary files a/public/img/logo.png and /dev/null differ
diff --git a/public/index.html b/public/index.html
deleted file mode 100644
index 22f7bae..0000000
--- a/public/index.html
+++ /dev/null
@@ -1,234 +0,0 @@
-
-
-
-
-
-
Bill Tracker
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Amount ($) *
-
-
-
-
- Paid Date *
-
-
-
-
- Method
-
- β
- Bank Transfer
- Card
- Autopay
- Check
- Cash
-
-
-
-
- Notes
-
-
-
-
-
-
-
-
-
-
-
🔒
-
Your data is private
-
- The admin account on this system can only:
-
-
- Create new user accounts
- Reset your password if you're locked out
-
-
- The admin cannot view, edit, or access your bills,
- payments, or any financial data β by design.
-
-
Got it, take me to my tracker
-
-
-
-
-
-
-
-
Change Your Password
-
- Your password was reset by the admin. Please set a new one to continue.
-
-
-
- New Password
-
-
-
- Confirm Password
-
-
-
- Set New Password
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/public/js/api.js b/public/js/api.js
deleted file mode 100644
index 1b1f64d..0000000
--- a/public/js/api.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/* Thin API client β all fetch calls go through here */
-
-const API = {
- async _fetch(method, path, body) {
- const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' };
- // Add CSRF token header for state-changing methods
- if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
- const name = 'bt_csrf_token';
- const match = document.cookie.match(new RegExp(name + '=([^;]+)'));
- if (match) opts.headers['x-csrf-token'] = match[1];
- }
- if (body !== undefined) opts.body = JSON.stringify(body);
- const res = await fetch('/api' + path, opts);
- const data = await res.json();
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
- return data;
- },
-
- get: (path) => API._fetch('GET', path),
- post: (path, body) => API._fetch('POST', path, body),
- put: (path, body) => API._fetch('PUT', path, body),
- delete: (path) => API._fetch('DELETE', path),
-
- // Tracker
- tracker: (year, month) => API.get(`/tracker?year=${year}&month=${month}`),
-
- // Bills
- bills: () => API.get('/bills'),
- allBills: () => API.get('/bills?inactive=true'),
- bill: (id) => API.get(`/bills/${id}`),
- createBill: (data) => API.post('/bills', data),
- updateBill: (id, d) => API.put(`/bills/${id}`, d),
- deleteBill: (id) => API.delete(`/bills/${id}`),
-
- // Payments
- payments: (billId, y, m) => API.get(`/payments?bill_id=${billId}&year=${y}&month=${m}`),
- quickPay: (data) => API.post('/payments/quick', data),
- createPayment: (data) => API.post('/payments', data),
- updatePayment: (id, data) => API.put(`/payments/${id}`, data),
- deletePayment: (id) => API.delete(`/payments/${id}`),
-
- // Categories
- categories: () => API.get('/categories'),
- createCategory: (name) => API.post('/categories', { name }),
- updateCategory: (id, n) => API.put(`/categories/${id}`, { name: n }),
- deleteCategory: (id) => API.delete(`/categories/${id}`),
-
- // Settings
- settings: () => API.get('/settings'),
- saveSettings: (data) => API.put('/settings', data),
-};
diff --git a/public/js/app.js b/public/js/app.js
deleted file mode 100644
index 726554c..0000000
--- a/public/js/app.js
+++ /dev/null
@@ -1,179 +0,0 @@
-/* ββ App bootstrap & routing ββ */
-
-const PAGES = {
- tracker: { container: 'page-tracker', init: (el) => TrackerPage.init(el) },
- bills: { container: 'page-bills', init: (el) => BillsPage.init(el) },
- categories: { container: 'page-categories', init: (el) => CategoriesPage.init(el) },
- settings: { container: 'page-settings', init: (el) => SettingsPage.init(el) },
- status: { container: 'page-status', init: (el) => StatusPage.init(el) },
-};
-
-let activePage = null;
-let currentUser = null;
-
-// ββ Auth gate ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-async function boot() {
- let res;
- try { res = await fetch('/api/auth/me'); }
- catch { location.href = '/login.html'; return; }
-
- if (!res.ok) { location.href = '/login.html'; return; }
-
- const data = await res.json();
- currentUser = data.user;
-
- if (currentUser.role === 'admin') { location.href = '/admin.html'; return; }
-
- document.getElementById('sidebar-username').textContent = currentUser.username;
-
- if (data.single_user_mode) {
- document.getElementById('sidebar-logout').style.display = 'none';
- startApp();
- return;
- }
-
- if (currentUser.must_change_password) { showChangePasswordOverlay(); return; }
- if (currentUser.first_login) { showPrivacyNotice(); return; }
-
- startApp();
-}
-
-// ββ Password change overlay ββββββββββββββββββββββββββββββββββββββββββββββββ
-
-function showChangePasswordOverlay() {
- document.getElementById('change-password-overlay').classList.remove('hidden');
-
- document.getElementById('change-password-form').onsubmit = async (e) => {
- e.preventDefault();
- const np = document.getElementById('cp-new').value;
- const cnf = document.getElementById('cp-confirm').value;
- const err = document.getElementById('cp-error');
-
- if (np !== cnf) { err.textContent = 'Passwords do not match.'; return; }
- if (np.length < 8) { err.textContent = 'Password must be at least 8 characters.'; return; }
- err.textContent = '';
-
- try {
- const res = await fetch('/api/auth/change-password', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ new_password: np }),
- });
- const body = await res.json();
- if (!res.ok) throw new Error(body.error);
- document.getElementById('change-password-overlay').classList.add('hidden');
- currentUser.must_change_password = false;
- currentUser.first_login ? showPrivacyNotice() : startApp();
- } catch (ex) {
- document.getElementById('cp-error').textContent = 'Error: ' + ex.message;
- }
- };
-}
-
-// ββ Privacy notice overlay βββββββββββββββββββββββββββββββββββββββββββββββββ
-
-function showPrivacyNotice() {
- document.getElementById('privacy-overlay').classList.remove('hidden');
- document.getElementById('privacy-ack-btn').onclick = async () => {
- await fetch('/api/auth/acknowledge-privacy', { method: 'POST' });
- document.getElementById('privacy-overlay').classList.add('hidden');
- currentUser.first_login = false;
- startApp();
- };
-}
-
-// ββ Main app βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-function startApp() {
- document.getElementById('app').style.visibility = 'visible';
- setupLogout();
- setupNavLinks();
- handleHash();
-}
-
-function setupLogout() {
- document.getElementById('sidebar-logout').addEventListener('click', async (e) => {
- e.preventDefault();
- await fetch('/api/auth/logout', { method: 'POST' });
- location.href = '/login.html';
- });
-}
-
-function setupNavLinks() {
- document.querySelectorAll('.nav-link[data-page]').forEach(link => {
- link.addEventListener('click', () => { activePage = null; navigate(link.dataset.page); });
- });
- window.addEventListener('hashchange', () => { activePage = null; handleHash(); });
-}
-
-function handleHash() {
- navigate(location.hash.replace('#', '') || 'tracker');
-}
-
-function navigate(page) {
- if (!PAGES[page]) page = 'tracker';
-
- document.querySelectorAll('.nav-link[data-page]').forEach(a => {
- a.classList.toggle('active', a.dataset.page === page);
- });
- document.querySelectorAll('.page').forEach(el => el.classList.remove('active'));
- document.getElementById(PAGES[page].container).classList.add('active');
-
- if (activePage !== page) {
- activePage = page;
- PAGES[page].init(document.getElementById(PAGES[page].container));
- }
-}
-
-// ββ Toast system βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-const TOAST_ICONS = {
- success: 'β',
- error: 'β',
- warning: 'β ',
- info: 'βΉ',
-};
-
-let toastContainer = null;
-
-function getToastContainer() {
- if (!toastContainer) {
- toastContainer = document.createElement('div');
- toastContainer.id = 'toast-container';
- document.body.appendChild(toastContainer);
- }
- return toastContainer;
-}
-
-function showToast(msg, type = 'info', duration = 3500) {
- const container = getToastContainer();
- const toast = document.createElement('div');
- const icon = TOAST_ICONS[type] || TOAST_ICONS.info;
-
- toast.className = `toast toast-${type}`;
- toast.innerHTML = `
- ${icon}
- ${msg}
- ×
-
- `;
-
- container.appendChild(toast);
- // Trigger animation
- requestAnimationFrame(() => toast.classList.add('toast-show'));
-
- const dismiss = () => {
- toast.classList.remove('toast-show');
- toast.classList.add('toast-hide');
- toast.addEventListener('transitionend', () => toast.remove(), { once: true });
- };
-
- const timer = setTimeout(dismiss, duration);
- toast.querySelector('.toast-close').onclick = () => { clearTimeout(timer); dismiss(); };
-}
-
-// Hide app shell until auth check completes (prevents flash)
-document.getElementById('app').style.visibility = 'hidden';
-
-boot();
diff --git a/public/js/bills.js b/public/js/bills.js
deleted file mode 100644
index 09e244e..0000000
--- a/public/js/bills.js
+++ /dev/null
@@ -1,161 +0,0 @@
-/* ββ Bills management page ββ */
-
-const BillsPage = (() => {
- let categories = [];
-
- const CYCLE_LABELS = {
- monthly: 'Monthly', quarterly: 'Quarterly',
- annually: 'Annually', irregular: 'Irregular',
- };
-
- function fmt(amount) {
- return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
- }
-
- function escHtml(str) {
- return String(str || '')
- .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
- }
-
- async function init(container) {
- container.innerHTML = `Loading...
`;
- try {
- [categories] = await Promise.all([API.categories()]);
- render(container);
- } catch (e) {
- container.innerHTML = `Failed to load: ${e.message}
`;
- }
- }
-
- async function render(container) {
- const bills = await API.allBills();
- const active = bills.filter(b => b.active);
- const inactive = bills.filter(b => !b.active);
-
- container.innerHTML = `
-
-
- ${active.map(b => renderCard(b)).join('')}
- ${inactive.length ? `
-
-
INACTIVE
- ${inactive.map(b => renderCard(b, true)).join('')}
-
` : ''}
- ${bills.length === 0 ? `
No bills yet. Add your first bill!
` : ''}
-
- `;
-
- document.getElementById('add-bill-btn').onclick = () => openBillModal(null, () => render(container));
-
- container.querySelectorAll('.btn-edit-bill').forEach(btn => {
- btn.onclick = async () => {
- const bill = await API.bill(btn.dataset.id);
- openBillModal(bill, () => render(container));
- };
- });
-
- container.querySelectorAll('.btn-toggle-bill').forEach(btn => {
- btn.onclick = async () => {
- const active = btn.dataset.active === '1';
- if (!active || confirm('Deactivate this bill? It will be hidden from the tracker.')) {
- await API.updateBill(btn.dataset.id, { active: active ? 0 : 1 });
- render(container);
- }
- };
- });
- }
-
- function renderCard(bill, inactive = false) {
- const catName = categories.find(c => c.id === bill.category_id)?.name || '';
- return `
-
-
-
${escHtml(bill.name)}
-
- Day ${bill.due_day}
- ${catName ? ` Β· ${escHtml(catName)}` : ''}
- Β· ${CYCLE_LABELS[bill.billing_cycle] || bill.billing_cycle}
- ${bill.autopay_enabled ? ' Β· Autopay ' : ''}
-
-
-
${fmt(bill.expected_amount)}
-
- Edit
-
- ${bill.active ? 'Deactivate' : 'Activate'}
-
-
-
- `;
- }
-
- function openBillModal(bill, onSave) {
- const modal = document.getElementById('bill-modal');
- const isNew = !bill;
-
- document.getElementById('bill-modal-title').textContent = isNew ? 'Add Bill' : 'Edit Bill';
- document.getElementById('bill-id').value = bill?.id || '';
- document.getElementById('bill-name').value = bill?.name || '';
- document.getElementById('bill-due-day').value = bill?.due_day || '';
- document.getElementById('bill-expected').value = bill?.expected_amount || '';
- document.getElementById('bill-interest-rate').value = bill?.interest_rate ?? '';
- document.getElementById('bill-cycle').value = bill?.billing_cycle || 'monthly';
- document.getElementById('bill-autopay').checked = !!bill?.autopay_enabled;
- document.getElementById('bill-2fa').checked = !!bill?.has_2fa;
- document.getElementById('bill-website').value = bill?.website || '';
- document.getElementById('bill-username').value = bill?.username || '';
- document.getElementById('bill-account-info').value = bill?.account_info || '';
- document.getElementById('bill-notes').value = bill?.notes || '';
-
- // Populate category select
- const catSelect = document.getElementById('bill-category');
- catSelect.innerHTML = 'β none β ' +
- categories.map(c => `${escHtml(c.name)} `).join('');
-
- modal.classList.remove('hidden');
-
- const close = () => modal.classList.add('hidden');
- document.getElementById('bill-modal-close').onclick = close;
- document.getElementById('bill-modal-cancel').onclick = close;
- modal.querySelector('.modal-overlay').onclick = close;
-
- document.getElementById('bill-form').onsubmit = async (e) => {
- e.preventDefault();
- const data = {
- name: document.getElementById('bill-name').value.trim(),
- category_id: document.getElementById('bill-category').value || null,
- due_day: parseInt(document.getElementById('bill-due-day').value, 10),
- expected_amount: parseFloat(document.getElementById('bill-expected').value) || 0,
- interest_rate: document.getElementById('bill-interest-rate').value === '' ? null : parseFloat(document.getElementById('bill-interest-rate').value),
- billing_cycle: document.getElementById('bill-cycle').value,
- autopay_enabled: document.getElementById('bill-autopay').checked,
- has_2fa: document.getElementById('bill-2fa').checked,
- website: document.getElementById('bill-website').value || null,
- username: document.getElementById('bill-username').value || null,
- account_info: document.getElementById('bill-account-info').value || null,
- notes: document.getElementById('bill-notes').value || null,
- };
-
- try {
- if (isNew) {
- await API.createBill(data);
- showToast('Bill added', 'success');
- } else {
- await API.updateBill(bill.id, data);
- showToast('Bill updated', 'success');
- }
- close();
- onSave();
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- }
- };
- }
-
- return { init };
-})();
diff --git a/public/js/categories.js b/public/js/categories.js
deleted file mode 100644
index ed137cc..0000000
--- a/public/js/categories.js
+++ /dev/null
@@ -1,110 +0,0 @@
-/* ββ Categories page ββ */
-
-const CategoriesPage = (() => {
- function escHtml(str) {
- return String(str || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
- }
-
- async function init(container) {
- await render(container);
- }
-
- async function render(container) {
- let cats;
- try {
- cats = await API.categories();
- } catch (e) {
- container.innerHTML = `Failed to load: ${e.message}
`;
- return;
- }
-
- container.innerHTML = `
-
-
-
- Add
-
-
- ${cats.map(c => renderItem(c)).join('')}
- ${cats.length === 0 ? `
` : ''}
-
- `;
-
- document.getElementById('cat-add-form').onsubmit = async (e) => {
- e.preventDefault();
- const name = document.getElementById('cat-new-name').value.trim();
- if (!name) return;
- try {
- await API.createCategory(name);
- document.getElementById('cat-new-name').value = '';
- showToast('Category added', 'success');
- render(container);
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- }
- };
-
- container.querySelectorAll('.btn-delete-cat').forEach(btn => {
- btn.onclick = async () => {
- if (!confirm('Delete this category? Bills using it will be uncategorized.')) return;
- try {
- await API.deleteCategory(btn.dataset.id);
- showToast('Category deleted', 'success');
- render(container);
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- }
- };
- });
-
- container.querySelectorAll('.cat-name-span').forEach(span => {
- span.ondblclick = () => startRename(span, cats.find(c => c.id == span.dataset.id), container);
- });
- }
-
- function renderItem(cat) {
- return `
-
-
- ${escHtml(cat.name)}
-
- Delete
-
- `;
- }
-
- function startRename(span, cat, container) {
- const input = document.createElement('input');
- input.type = 'text';
- input.value = cat.name;
- input.className = 'cat-name';
- input.style.flex = '1';
- span.replaceWith(input);
- input.focus();
- input.select();
-
- async function commit() {
- const name = input.value.trim();
- if (!name || name === cat.name) { render(container); return; }
- try {
- await API.updateCategory(cat.id, name);
- showToast('Renamed', 'success');
- render(container);
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- render(container);
- }
- }
-
- input.addEventListener('blur', commit);
- input.addEventListener('keydown', e => {
- if (e.key === 'Enter') input.blur();
- if (e.key === 'Escape') render(container);
- });
- }
-
- return { init };
-})();
diff --git a/public/js/settings.js b/public/js/settings.js
deleted file mode 100644
index dd864c2..0000000
--- a/public/js/settings.js
+++ /dev/null
@@ -1,207 +0,0 @@
-/* ββ Settings page ββ */
-
-const SettingsPage = (() => {
- async function init(container) {
- let settings, notifPrefs;
- try {
- [settings, notifPrefs] = await Promise.all([
- API.settings(),
- fetch('/api/notifications/me').then(r => r.ok ? r.json() : null),
- ]);
- } catch (e) {
- container.innerHTML = `
`;
- return;
- }
-
- const notifSection = buildNotifSection(notifPrefs);
-
- container.innerHTML = `
-
-
-
-
General
-
- Currency
-
- USD $
- EUR β¬
- GBP Β£
- CAD $
-
-
-
- Date Format
-
- MM/DD/YYYY
- DD/MM/YYYY
- YYYY-MM-DD
-
-
-
-
-
-
Billing Behavior
-
- Grace Period (days)
-
-
-
-
-
-
- ${notifSection}
-
-
- Save Settings
-
- `;
-
- document.getElementById('save-settings-btn').onclick = async () => {
- const data = {
- currency: document.getElementById('s-currency').value,
- date_format: document.getElementById('s-date-format').value,
- grace_period_days: document.getElementById('s-grace').value,
- backup_enabled: document.getElementById('s-backup-enabled').checked ? 'true' : 'false',
- backup_frequency_days: document.getElementById('s-backup-freq').value,
- backup_keep_count: document.getElementById('s-backup-keep').value,
- };
- try {
- await API.saveSettings(data);
- showToast('Settings saved', 'success');
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- }
- };
-
- // Notification save (only wired if section exists)
- const notifForm = document.getElementById('notif-user-form');
- if (notifForm) {
- notifForm.onsubmit = async (e) => {
- e.preventDefault();
- const btn = notifForm.querySelector('[type="submit"]');
- btn.disabled = true;
- try {
- const res = await fetch('/api/notifications/me', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- notification_email: document.getElementById('n-email').value.trim(),
- notifications_enabled:document.getElementById('n-enabled').checked,
- notify_3d: document.getElementById('n-3d').checked,
- notify_1d: document.getElementById('n-1d').checked,
- notify_due: document.getElementById('n-due').checked,
- notify_overdue: document.getElementById('n-overdue').checked,
- }),
- });
- if (!res.ok) throw new Error((await res.json()).error);
- showToast('Notification preferences saved', 'success');
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- } finally {
- btn.disabled = false;
- }
- };
-
- // Toggle field visibility based on enabled checkbox
- const toggle = () => {
- const on = document.getElementById('n-enabled').checked;
- document.getElementById('n-options').style.opacity = on ? '1' : '.4';
- document.getElementById('n-options').style.pointerEvents = on ? '' : 'none';
- };
- document.getElementById('n-enabled').addEventListener('change', toggle);
- toggle();
- }
- }
-
- function buildNotifSection(p) {
- if (!p) return ''; // API call failed, skip silently
-
- if (!p.smtp_enabled) {
- return `
-
-
Notifications
-
- Email notifications have not been configured by the admin.
-
-
`;
- }
-
- if (!p.allow_user_config) {
- return `
-
-
Notifications
-
- Email notifications are enabled and managed by the admin.
- Your bills will generate reminders automatically.
-
-
`;
- }
-
- // Full user-configurable notification section
- const chk = (id, label, val) =>
- `
- ${label}
- `;
-
- return `
-
-
Notifications
-
-
- Enable Notifications
-
-
- Send me email reminders
-
-
-
-
-
- Notification Email
-
-
-
-
-
Remind me
-
- ${chk('n-3d', '3 days before due', p.notify_3d)}
- ${chk('n-1d', '1 day before due', p.notify_1d)}
- ${chk('n-due', 'On the day it\'s due', p.notify_due)}
- ${chk('n-overdue', 'Daily while overdue', p.notify_overdue)}
-
-
-
-
-
- Save Notifications
-
-
-
`;
- }
-
- function escHtml(s) {
- return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
- }
-
- return { init };
-})();
diff --git a/public/js/status.js b/public/js/status.js
deleted file mode 100644
index 157591f..0000000
--- a/public/js/status.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/* ββ Status page ββ */
-
-const StatusPage = (() => {
- async function init(container) {
- container.innerHTML = `
-
-
- `;
- document.getElementById('status-refresh').onclick = () => load(container);
- load(container);
- }
-
- async function load(container) {
- const body = document.getElementById('status-body');
- try {
- const res = await fetch('/api/status');
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const d = await res.json();
- body.innerHTML = render(d);
- } catch (e) {
- body.innerHTML = `
Failed to load status: ${e.message}
`;
- }
- }
-
- function fmtUptime(seconds) {
- const d = Math.floor(seconds / 86400);
- const h = Math.floor((seconds % 86400) / 3600);
- const m = Math.floor((seconds % 3600) / 60);
- const s = seconds % 60;
- if (d > 0) return `${d}d ${h}h ${m}m`;
- if (h > 0) return `${h}h ${m}m ${s}s`;
- if (m > 0) return `${m}m ${s}s`;
- return `${s}s`;
- }
-
- function fmtBytes(bytes) {
- if (bytes === 0) return '0 B';
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
- return `${(bytes / 1048576).toFixed(2)} MB`;
- }
-
- function row(label, value, note = '') {
- return `
-
- ${label}
- ${value}${note ? `${note} ` : ''}
-
`;
- }
-
- function render(d) {
- const dbOk = d.database.status === 'connected';
- return `
-
-
-
-
- ${row('Version', `v${d.app.version}`)}
- ${row('Environment', d.app.environment)}
- ${row('Uptime', fmtUptime(d.app.uptime_seconds))}
-
-
-
-
- ${row('Node.js', d.runtime.node_version)}
- ${row('Platform', `${d.runtime.platform} / ${d.runtime.arch}`)}
- ${row('Memory', `${d.runtime.memory_mb} MB`)}
-
-
-
-
- ${row('Status', dbOk ? 'Connected' : 'Error')}
- ${row('Size', fmtBytes(d.database.size_bytes))}
- ${row('File', `${d.database.path}`)}
-
-
-
-
- ${row('Active Bills', d.stats.active_bills)}
- ${row('Total Payments', d.stats.total_payments)}
- ${row('Users', d.stats.users)}
- ${row('Active Sessions', d.stats.active_sessions)}
-
-
-
- `;
- }
-
- return { init };
-})();
diff --git a/public/js/tracker.js b/public/js/tracker.js
deleted file mode 100644
index 4417441..0000000
--- a/public/js/tracker.js
+++ /dev/null
@@ -1,424 +0,0 @@
-/* ββ Tracker page ββ */
-
-const TrackerPage = (() => {
- let currentYear, currentMonth;
- let trackerData = null;
-
- const MONTH_NAMES = [
- 'January','February','March','April','May','June',
- 'July','August','September','October','November','December',
- ];
-
- const STATUS_META = {
- paid: { label: 'Paid', cls: 'badge-paid' },
- upcoming: { label: 'Upcoming', cls: 'badge-upcoming' },
- due_soon: { label: 'Due Soon', cls: 'badge-due-soon' },
- late: { label: 'Late', cls: 'badge-late' },
- missed: { label: 'Missed', cls: 'badge-missed' },
- autodraft: { label: 'Autodraft', cls: 'badge-autodraft' },
- };
-
- function fmt(amount) {
- return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
- }
-
- function fmtDate(dateStr) {
- if (!dateStr) return '';
- const [y, m, d] = dateStr.split('-');
- return `${parseInt(m)}/${parseInt(d)}/${y}`;
- }
-
- function todayStr() {
- return new Date().toISOString().slice(0, 10);
- }
-
- function init(container) {
- const now = new Date();
- currentYear = now.getFullYear();
- currentMonth = now.getMonth() + 1;
- render(container);
- }
-
- function render(container) {
- container.innerHTML = `
-
-
-
- `;
-
- document.getElementById('month-label').textContent =
- `${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
-
- document.getElementById('prev-month').onclick = () => navigate(-1, container);
- document.getElementById('next-month').onclick = () => navigate(1, container);
- document.getElementById('today-btn').onclick = () => {
- const now = new Date();
- currentYear = now.getFullYear();
- currentMonth = now.getMonth() + 1;
- loadData(container);
- };
-
- loadData(container);
- }
-
- function navigate(delta, container) {
- currentMonth += delta;
- if (currentMonth > 12) { currentMonth = 1; currentYear++; }
- if (currentMonth < 1) { currentMonth = 12; currentYear--; }
- document.getElementById('month-label').textContent =
- `${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
- loadData(container);
- }
-
- async function loadData(container) {
- document.getElementById('tracker-body').innerHTML = '
Loading...
';
- try {
- trackerData = await API.tracker(currentYear, currentMonth);
- renderSummary(trackerData.summary);
- renderRows(trackerData.rows, container);
- } catch (e) {
- document.getElementById('tracker-body').innerHTML =
- `
Failed to load tracker: ${e.message}
`;
- }
- }
-
- function renderSummary(s) {
- document.getElementById('summary-bar').innerHTML = `
-
-
Total Expected
-
${fmt(s.total_expected)}
-
-
-
Total Paid
-
${fmt(s.total_paid)}
-
-
-
Remaining
-
${fmt(s.remaining)}
-
-
-
Overdue
-
${fmt(s.overdue)}
-
- `;
- }
-
- function renderRows(rows, container) {
- const body = document.getElementById('tracker-body');
- if (!rows || rows.length === 0) {
- body.innerHTML = `
`;
- return;
- }
-
- const first = rows.filter(r => r.bucket === '1st');
- const second = rows.filter(r => r.bucket === '15th');
-
- body.innerHTML = '';
- if (first.length) body.appendChild(renderBucket('1stβ14th', first));
- if (second.length) body.appendChild(renderBucket('15thβ31st', second));
-
- // Attach event listeners after render
- attachTableListeners(container);
- }
-
- function renderBucket(label, rows) {
- const totalExpected = rows.reduce((s, r) => s + r.expected_amount, 0);
- const totalPaid = rows.reduce((s, r) => s + r.total_paid, 0);
-
- const section = document.createElement('div');
- section.className = 'bucket-section';
- section.innerHTML = `
-
-
-
-
- Bill
- Due
- Expected
- Amount Paid
- Paid Date
- Status
-
-
-
-
- ${rows.map(renderRow).join('')}
-
-
- `;
- return section;
- }
-
- function renderRow(row) {
- const meta = STATUS_META[row.status] || STATUS_META.upcoming;
- const rowCls = `row-${row.status}`;
-
- const paidDate = row.last_paid_date ? fmtDate(row.last_paid_date) : '';
- const paidAmt = row.total_paid > 0 ? fmt(row.total_paid) : '';
- const mismatch = row.total_paid > 0 && row.total_paid !== row.expected_amount;
- const isPaid = row.status === 'paid' || row.status === 'autodraft';
-
- const autopayDot = row.autopay_enabled
- ? `
`
- : '';
-
- return `
-
-
-
- ${autopayDot}
-
-
${escHtml(row.name)}
- ${row.category_name ? `
${escHtml(row.category_name)}
` : ''}
-
-
-
-
- ${fmtDate(row.due_date)}
-
-
- ${fmt(row.expected_amount)}
-
-
-
-
- ${paidAmt || 'β'}
-
-
-
-
-
-
- ${paidDate || 'β'}
-
-
-
-
-
- ${meta.label}
-
-
-
-
- ${!isPaid ? `
-
-
- Pay
-
` : ''}
- ${row.payments && row.payments.length > 0
- ? `
✎ `
- : ''}
-
-
-
- `;
- }
-
- function attachTableListeners(container) {
- // Quick pay buttons β read amount from the sibling input
- container.querySelectorAll('.btn-quick-pay').forEach(btn => {
- btn.onclick = async (e) => {
- e.stopPropagation();
- const billId = btn.dataset.billId;
- const amtInput = btn.closest('.quick-pay-group')?.querySelector('.quick-pay-amount');
- const amount = amtInput ? parseFloat(amtInput.value) || 0 : 0;
- if (amount <= 0) { showToast('Enter a payment amount', 'error'); return; }
- btn.disabled = true;
- try {
- await API.quickPay({ bill_id: billId, amount, paid_date: todayStr() });
- showToast('Marked as paid', 'success');
- loadData(container);
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- btn.disabled = false;
- }
- };
- });
-
- // Edit payment buttons
- container.querySelectorAll('.btn-edit-payment').forEach(btn => {
- btn.onclick = (e) => {
- e.stopPropagation();
- const payment = JSON.parse(btn.dataset.payment);
- openPaymentModal(payment, () => loadData(container));
- };
- });
-
- // Inline editable amount cells
- container.querySelectorAll('.amount-cell').forEach(cell => {
- cell.onclick = () => startInlineEdit(cell, 'number', container);
- });
-
- // Inline editable date cells
- container.querySelectorAll('.date-cell').forEach(cell => {
- cell.onclick = () => startInlineEdit(cell, 'date', container);
- });
- }
-
- function startInlineEdit(cell, type, container) {
- if (cell.querySelector('input')) return; // already editing
-
- const billId = cell.dataset.billId;
- const field = cell.dataset.field;
- const row = trackerData?.rows?.find(r => r.id == billId);
- if (!row) return;
-
- let currentVal = '';
- if (field === 'amount') currentVal = row.total_paid > 0 ? String(row.total_paid) : '';
- if (field === 'date') currentVal = row.last_paid_date || '';
-
- const input = document.createElement('input');
- input.type = type === 'date' ? 'date' : 'number';
- if (type === 'number') { input.step = '0.01'; input.min = '0'; }
- input.value = currentVal;
- input.style.cssText = 'width:100%;min-width:80px;';
-
- const origText = cell.textContent.trim();
- cell.textContent = '';
- cell.appendChild(input);
- cell.classList.remove('empty');
- input.focus();
- input.select();
-
- async function commit() {
- const val = input.value.trim();
- if (!val) { cell.textContent = origText || 'β'; cell.classList.add('empty'); return; }
-
- try {
- if (row.payments && row.payments.length > 0) {
- const p = row.payments[0];
- const update = {};
- if (field === 'amount') update.amount = parseFloat(val);
- if (field === 'date') update.paid_date = val;
- await API.updatePayment(p.id, update);
- } else {
- // Create new payment
- const paidDate = field === 'date' ? val : todayStr();
- const amount = field === 'amount' ? parseFloat(val) : row.expected_amount;
- await API.createPayment({ bill_id: billId, amount, paid_date: paidDate });
- }
- showToast('Saved', 'success');
- loadData(container);
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- cell.textContent = origText || 'β';
- }
- }
-
- input.addEventListener('blur', commit);
- input.addEventListener('keydown', e => {
- if (e.key === 'Enter') input.blur();
- if (e.key === 'Escape') {
- cell.textContent = origText || 'β';
- if (!origText) cell.classList.add('empty');
- }
- });
- }
-
-function openPaymentModal(payment, onSave) {
- const modal = document.getElementById('payment-modal');
-
- document.getElementById('payment-bill-id').value = payment.bill_id;
- document.getElementById('payment-id').value = payment.id;
- document.getElementById('payment-amount').value = payment.amount;
- document.getElementById('payment-date').value = payment.paid_date;
- document.getElementById('payment-method').value = payment.method || '';
- document.getElementById('payment-notes').value = payment.notes || '';
-
- document.getElementById('payment-modal-title').textContent = 'Edit Payment';
-
- modal.classList.remove('hidden');
-
- const close = () => modal.classList.add('hidden');
-
- document.getElementById('payment-modal-close').onclick = close;
- document.getElementById('payment-modal-cancel').onclick = close;
- modal.querySelector('.modal-overlay').onclick = close;
-
- // β
UPDATED DELETE LOGIC WITH CLEAR INTENT
- document.getElementById('payment-delete').onclick = async () => {
- const confirmDelete = confirm(
- 'Remove this payment?\n\n' +
- '- The BILL will NOT be deleted\n' +
- '- This will remove the payment record\n' +
- '- The bill will become UNPAID\n\n' +
- 'Continue?'
- );
-
- if (!confirmDelete) return;
-
- try {
- await API.deletePayment(payment.id);
-
- close();
-
- showToast(
- 'Payment removed. Bill is now marked as unpaid.',
- 'success'
- );
-
- onSave(); // refresh tracker
-
- } catch (e) {
- showToast('Error: ' + e.message, 'error');
- }
- };
-
- // normal save logic untouched
- document.getElementById('payment-form').onsubmit = async (e) => {
- e.preventDefault();
-
- const data = {
- amount: parseFloat(document.getElementById('payment-amount').value),
- paid_date: document.getElementById('payment-date').value,
- method: document.getElementById('payment-method').value || null,
- notes: document.getElementById('payment-notes').value || null,
- };
-
- try {
- await API.updatePayment(payment.id, data);
-
- close();
- showToast('Payment saved', 'success');
- onSave();
-
- } catch (err) {
- showToast('Error: ' + err.message, 'error');
- }
- };
-}
-
- function escHtml(str) {
- return String(str || '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
- }
-
- return { init };
-})();
diff --git a/public/login.html b/public/login.html
deleted file mode 100644
index d6d423b..0000000
--- a/public/login.html
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
BillTracker β Sign In
-
-
-
-
Continue to BillTracker sign in
-
-
diff --git a/server.js b/server.js
index 57d68ce..7134b0f 100644
--- a/server.js
+++ b/server.js
@@ -152,8 +152,10 @@ app.all('/api/*', (req, res) => {
});
});
-// ββ Legacy UI ("Remember When" mode) βββββββββββββββββββββββββββββββββββββββββ
-app.use('/legacy', express.static(path.join(__dirname, 'legacy')));
+// ββ Retired legacy UI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+app.all(['/legacy', '/legacy/*'], (req, res) => {
+ res.status(410).send('The legacy UI has been retired.');
+});
// ββ Modern UI (Vite build) ββββββββββββββββββββββββββββββββββββββββββββββββββββ
app.get('/login.html', (req, res) => res.redirect(302, '/login'));