๐Ÿ’ธ

SplitEasy

Split expenses, settle up fast

Add Expense

Add people first

All Expenses

) ? value : ' default: return value; } } function personHasExpenses(personId) { return state.expenses.some(e => e.payerId === personId || (e.splitAmong && e.splitAmong.includes(personId))); } // โ”€โ”€ Calculations โ”€โ”€ function calcBalances() { const balances = {}; state.people.forEach(p => { balances[p.id] = 0; }); state.expenses.forEach(exp => { const amount = Number(exp.amount); const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = amount / split.length; // Payer paid full amount if (balances[exp.payerId] !== undefined) { balances[exp.payerId] += amount; } // Each person in split owes their share split.forEach(id => { if (balances[id] !== undefined) { balances[id] -= share; } }); }); return balances; } function calcSettlements() { const balances = calcBalances(); const debtors = []; const creditors = []; Object.entries(balances).forEach(([id, bal]) => { if (bal < -0.01) debtors.push({ id, amount: Math.abs(bal) }); else if (bal > 0.01) creditors.push({ id, amount: bal }); }); debtors.sort((a, b) => b.amount - a.amount); creditors.sort((a, b) => b.amount - a.amount); const settlements = []; let di = 0, ci = 0; while (di < debtors.length && ci < creditors.length) { const transfer = Math.min(debtors[di].amount, creditors[ci].amount); if (transfer > 0.01) { settlements.push({ from: debtors[di].id, to: creditors[ci].id, amount: transfer }); } debtors[di].amount -= transfer; creditors[ci].amount -= transfer; if (debtors[di].amount < 0.01) di++; if (creditors[ci].amount < 0.01) ci++; } return settlements; } // โ”€โ”€ DOM refs โ”€โ”€ const $ = id => document.getElementById(id); // โ”€โ”€ Tab Management โ”€โ”€ let activeTab = 'expenses'; function initTabs() { document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { activeTab = btn.dataset.tab; document.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none'); const tab = $('tab-' + activeTab); if (tab) tab.style.display = 'block'; updateTabStyles(); }); }); updateTabStyles(); $('tab-expenses').style.display = 'block'; } function updateTabStyles() { document.querySelectorAll('.tab-btn').forEach(btn => { if (btn.dataset.tab === activeTab) { btn.style.background = '#111827'; btn.style.color = '#fff'; } else { btn.style.background = 'transparent'; btn.style.color = '#6b7280'; } }); } // โ”€โ”€ Delete Confirmation Toast โ”€โ”€ let pendingDelete = null; function showDeleteToast(msg, onConfirm) { const toast = $('delete-toast'); const inner = toast.querySelector('div'); $('delete-toast-msg').textContent = msg; pendingDelete = onConfirm; toast.classList.remove('hidden'); inner.classList.remove('toast-out'); inner.classList.add('toast-in'); } function hideDeleteToast() { const toast = $('delete-toast'); const inner = toast.querySelector('div'); inner.classList.remove('toast-in'); inner.classList.add('toast-out'); setTimeout(() => { toast.classList.add('hidden'); inner.classList.remove('toast-out'); }, 200); pendingDelete = null; } $('delete-toast-confirm').addEventListener('click', () => { if (pendingDelete) pendingDelete(); hideDeleteToast(); }); $('delete-toast-cancel').addEventListener('click', hideDeleteToast); // โ”€โ”€ Render โ”€โ”€ function render() { renderPeopleList(); renderPaymentLinks(); renderPayerDropdown(); renderSplitCheckboxes(); renderExpenseList(); renderSettleTab(); } // โ”€โ”€ People โ”€โ”€ function renderPeopleList() { const el = $('people-list'); if (state.people.length === 0) { el.innerHTML = '
๐Ÿ‘ฅ
No people added yet.
Add someone to get started!
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentType)}: ${esc(p.paymentLink)}
` : ''}
`; }).join(''); } function renderPaymentLinks() { const el = $('payment-links-list'); if (state.people.length === 0) { el.innerHTML = '
Add people first
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentLink)}
` : '
No link set
'}
`; }).join(''); } function renderPayerDropdown() { const el = $('expense-payer'); const current = el.value; el.innerHTML = '' + state.people.map(p => ``).join(''); if (current && state.people.some(p => p.id === current)) el.value = current; } function renderSplitCheckboxes() { const el = $('split-checkboxes'); if (state.people.length === 0) { el.innerHTML = 'Add people first'; return; } el.innerHTML = state.people.map(p => `` ).join(''); } // โ”€โ”€ Expenses โ”€โ”€ function renderExpenseList() { const el = $('expense-list'); if (state.expenses.length === 0) { el.innerHTML = '
๐Ÿงพ
No expenses yet.
Add one above to get started!
'; return; } el.innerHTML = state.expenses.map(e => { const splitNames = (e.splitAmong || []).map(id => personName(id)).filter(n => n !== 'Unknown'); const splitText = splitNames.length === state.people.length ? 'everyone' : splitNames.join(', '); return `
${esc(e.desc)}
Paid by ${esc(personName(e.payerId))} ยท Split: ${esc(splitText)}
${money.format(e.amount)}
`; }).join(''); } // โ”€โ”€ Settle Tab โ”€โ”€ function renderSettleTab() { const total = state.expenses.reduce((s, e) => s + Number(e.amount), 0); $('total-amount').textContent = money.format(total); const balEl = $('balance-list'); const setEl = $('settlement-list'); if (state.people.length === 0 || state.expenses.length === 0) { balEl.innerHTML = '
๐Ÿ“Š
Add people and expenses to see balances.
'; setEl.innerHTML = ''; return; } const balances = calcBalances(); const paid = {}; state.people.forEach(p => { paid[p.id] = 0; }); state.expenses.forEach(e => { if (paid[e.payerId] !== undefined) paid[e.payerId] += Number(e.amount); }); // Compute each person's total share const shares = {}; state.people.forEach(p => { shares[p.id] = 0; }); state.expenses.forEach(exp => { const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = Number(exp.amount) / split.length; split.forEach(id => { if (shares[id] !== undefined) shares[id] += share; }); }); balEl.innerHTML = state.people.map(p => { const bal = balances[p.id]; const paidAmt = paid[p.id]; const shareAmt = shares[p.id]; let badge = ''; let color = ''; if (bal > 0.01) { badge = `+${money.format(bal)}`; color = 'bg-emerald-50'; } else if (bal < -0.01) { badge = `-${money.format(Math.abs(bal))}`; color = 'bg-red-50'; } else { badge = `Settled`; color = 'bg-gray-50'; } return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
Paid ${money.format(paidAmt)} ยท Share ${money.format(shareAmt)}
${badge}
`; }).join(''); // Settlements const settlements = calcSettlements(); if (settlements.length === 0) { setEl.innerHTML = '
โœ…
All settled up!
'; } else { setEl.innerHTML = settlements.map(s => { const toPerson = personById(s.to); const hasPayLink = toPerson && toPerson.paymentLink; let payBtn = ''; if (hasPayLink) { const url = getPayUrl(toPerson.paymentType, toPerson.paymentLink); payBtn = `${getPayIcon(toPerson.paymentType)} Pay`; } return `
${esc(personName(s.from).charAt(0).toUpperCase())}
${esc(personName(s.from))}
${esc(personName(s.to).charAt(0).toUpperCase())}
${esc(personName(s.to))}
${money.format(s.amount)} ${payBtn}
`; }).join(''); } } // โ”€โ”€ Event Handlers โ”€โ”€ // Add person $('btn-add-person').addEventListener('click', addPerson); $('new-person-name').addEventListener('keydown', e => { if (e.key === 'Enter') addPerson(); }); function addPerson() { const input = $('new-person-name'); const name = input.value.trim(); if (!name) return; if (state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { input.value = ''; return; } state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); input.value = ''; save(); render(); } // Delete person (delegated) $('people-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-person]'); if (!btn) return; const id = btn.dataset.deletePerson; const person = personById(id); if (!person) return; if (personHasExpenses(id)) { showDeleteToast(`${person.name} has expenses. Delete anyway?`, () => { // Remove person from all expense splitAmong arrays state.expenses.forEach(exp => { if (exp.splitAmong) { exp.splitAmong = exp.splitAmong.filter(pid => pid !== id); } }); // Remove expenses where this person is the payer state.expenses = state.expenses.filter(exp => exp.payerId !== id); state.people = state.people.filter(p => p.id !== id); save(); render(); }); } else { state.people = state.people.filter(p => p.id !== id); save(); render(); } }); // Add expense $('btn-add-expense').addEventListener('click', addExpense); $('expense-desc').addEventListener('keydown', e => { if (e.key === 'Enter') $('expense-amount').focus(); }); $('expense-amount').addEventListener('keydown', e => { if (e.key === 'Enter') addExpense(); }); function addExpense() { const desc = $('expense-desc').value.trim(); const amount = parseFloat($('expense-amount').value); const payerId = $('expense-payer').value; if (!desc) { $('expense-desc').focus(); return; } if (!amount || amount <= 0) { $('expense-amount').focus(); return; } if (!payerId) { $('expense-payer').focus(); return; } const checked = Array.from(document.querySelectorAll('.split-cb:checked')).map(cb => cb.value); if (checked.length === 0) { alert('Select at least one person to split among.'); return; } state.expenses.unshift({ id: uid(), desc, amount, payerId, splitAmong: checked, date: new Date().toISOString() }); $('expense-desc').value = ''; $('expense-amount').value = ''; save(); render(); } // Delete expense (delegated) $('expense-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-expense]'); if (!btn) return; const id = btn.dataset.deleteExpense; const expense = state.expenses.find(ex => ex.id === id); if (!expense) return; showDeleteToast(`Delete "${expense.desc}"?`, () => { state.expenses = state.expenses.filter(ex => ex.id !== id); save(); render(); }); }); // Toggle all split checkboxes $('btn-toggle-all').addEventListener('click', () => { const cbs = document.querySelectorAll('.split-cb'); const allChecked = Array.from(cbs).every(cb => cb.checked); cbs.forEach(cb => { cb.checked = !allChecked; }); $('btn-toggle-all').textContent = allChecked ? 'Select All' : 'Deselect All'; }); // Payment link modal let modalTarget = null; $('payment-links-list').addEventListener('click', e => { const btn = e.target.closest('[data-setlink]'); if (!btn) return; const id = btn.dataset.setlink; const person = personById(id); if (!person) return; modalTarget = id; $('modal-title').textContent = `Payment Link for ${person.name}`; $('modal-pay-type').value = person.paymentType || 'venmo'; $('modal-pay-link').value = person.paymentLink || ''; $('payment-modal').classList.remove('hidden'); }); $('modal-cancel').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-backdrop').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-save').addEventListener('click', () => { if (!modalTarget) return; const person = personById(modalTarget); if (!person) return; const type = $('modal-pay-type').value; const link = $('modal-pay-link').value.trim(); person.paymentType = link ? type : null; person.paymentLink = link || null; save(); render(); $('payment-modal').classList.add('hidden'); }); // โ”€โ”€ Init โ”€โ”€ load(); // Add Bosco and Ivan if not already present ['Bosco', 'Ivan'].forEach(name => { if (!state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); } }); save(); initTabs(); render(); })(); + value}`; default: return value; } } function personHasExpenses(personId) { return state.expenses.some(e => e.payerId === personId || (e.splitAmong && e.splitAmong.includes(personId))); } // โ”€โ”€ Calculations โ”€โ”€ function calcBalances() { const balances = {}; state.people.forEach(p => { balances[p.id] = 0; }); state.expenses.forEach(exp => { const amount = Number(exp.amount); const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = amount / split.length; // Payer paid full amount if (balances[exp.payerId] !== undefined) { balances[exp.payerId] += amount; } // Each person in split owes their share split.forEach(id => { if (balances[id] !== undefined) { balances[id] -= share; } }); }); return balances; } function calcSettlements() { const balances = calcBalances(); const debtors = []; const creditors = []; Object.entries(balances).forEach(([id, bal]) => { if (bal < -0.01) debtors.push({ id, amount: Math.abs(bal) }); else if (bal > 0.01) creditors.push({ id, amount: bal }); }); debtors.sort((a, b) => b.amount - a.amount); creditors.sort((a, b) => b.amount - a.amount); const settlements = []; let di = 0, ci = 0; while (di < debtors.length && ci < creditors.length) { const transfer = Math.min(debtors[di].amount, creditors[ci].amount); if (transfer > 0.01) { settlements.push({ from: debtors[di].id, to: creditors[ci].id, amount: transfer }); } debtors[di].amount -= transfer; creditors[ci].amount -= transfer; if (debtors[di].amount < 0.01) di++; if (creditors[ci].amount < 0.01) ci++; } return settlements; } // โ”€โ”€ DOM refs โ”€โ”€ const $ = id => document.getElementById(id); // โ”€โ”€ Tab Management โ”€โ”€ let activeTab = 'expenses'; function initTabs() { document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { activeTab = btn.dataset.tab; document.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none'); const tab = $('tab-' + activeTab); if (tab) tab.style.display = 'block'; updateTabStyles(); }); }); updateTabStyles(); $('tab-expenses').style.display = 'block'; } function updateTabStyles() { document.querySelectorAll('.tab-btn').forEach(btn => { if (btn.dataset.tab === activeTab) { btn.style.background = '#111827'; btn.style.color = '#fff'; } else { btn.style.background = 'transparent'; btn.style.color = '#6b7280'; } }); } // โ”€โ”€ Delete Confirmation Toast โ”€โ”€ let pendingDelete = null; function showDeleteToast(msg, onConfirm) { const toast = $('delete-toast'); const inner = toast.querySelector('div'); $('delete-toast-msg').textContent = msg; pendingDelete = onConfirm; toast.classList.remove('hidden'); inner.classList.remove('toast-out'); inner.classList.add('toast-in'); } function hideDeleteToast() { const toast = $('delete-toast'); const inner = toast.querySelector('div'); inner.classList.remove('toast-in'); inner.classList.add('toast-out'); setTimeout(() => { toast.classList.add('hidden'); inner.classList.remove('toast-out'); }, 200); pendingDelete = null; } $('delete-toast-confirm').addEventListener('click', () => { if (pendingDelete) pendingDelete(); hideDeleteToast(); }); $('delete-toast-cancel').addEventListener('click', hideDeleteToast); // โ”€โ”€ Render โ”€โ”€ function render() { renderPeopleList(); renderPaymentLinks(); renderPayerDropdown(); renderSplitCheckboxes(); renderExpenseList(); renderSettleTab(); } // โ”€โ”€ People โ”€โ”€ function renderPeopleList() { const el = $('people-list'); if (state.people.length === 0) { el.innerHTML = '
๐Ÿ‘ฅ
No people added yet.
Add someone to get started!
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentType)}: ${esc(p.paymentLink)}
` : ''}
`; }).join(''); } function renderPaymentLinks() { const el = $('payment-links-list'); if (state.people.length === 0) { el.innerHTML = '
Add people first
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentLink)}
` : '
No link set
'}
`; }).join(''); } function renderPayerDropdown() { const el = $('expense-payer'); const current = el.value; el.innerHTML = '' + state.people.map(p => ``).join(''); if (current && state.people.some(p => p.id === current)) el.value = current; } function renderSplitCheckboxes() { const el = $('split-checkboxes'); if (state.people.length === 0) { el.innerHTML = 'Add people first'; return; } el.innerHTML = state.people.map(p => `` ).join(''); } // โ”€โ”€ Expenses โ”€โ”€ function renderExpenseList() { const el = $('expense-list'); if (state.expenses.length === 0) { el.innerHTML = '
๐Ÿงพ
No expenses yet.
Add one above to get started!
'; return; } el.innerHTML = state.expenses.map(e => { const splitNames = (e.splitAmong || []).map(id => personName(id)).filter(n => n !== 'Unknown'); const splitText = splitNames.length === state.people.length ? 'everyone' : splitNames.join(', '); return `
${esc(e.desc)}
Paid by ${esc(personName(e.payerId))} ยท Split: ${esc(splitText)}
${money.format(e.amount)}
`; }).join(''); } // โ”€โ”€ Settle Tab โ”€โ”€ function renderSettleTab() { const total = state.expenses.reduce((s, e) => s + Number(e.amount), 0); $('total-amount').textContent = money.format(total); const balEl = $('balance-list'); const setEl = $('settlement-list'); if (state.people.length === 0 || state.expenses.length === 0) { balEl.innerHTML = '
๐Ÿ“Š
Add people and expenses to see balances.
'; setEl.innerHTML = ''; return; } const balances = calcBalances(); const paid = {}; state.people.forEach(p => { paid[p.id] = 0; }); state.expenses.forEach(e => { if (paid[e.payerId] !== undefined) paid[e.payerId] += Number(e.amount); }); // Compute each person's total share const shares = {}; state.people.forEach(p => { shares[p.id] = 0; }); state.expenses.forEach(exp => { const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = Number(exp.amount) / split.length; split.forEach(id => { if (shares[id] !== undefined) shares[id] += share; }); }); balEl.innerHTML = state.people.map(p => { const bal = balances[p.id]; const paidAmt = paid[p.id]; const shareAmt = shares[p.id]; let badge = ''; let color = ''; if (bal > 0.01) { badge = `+${money.format(bal)}`; color = 'bg-emerald-50'; } else if (bal < -0.01) { badge = `-${money.format(Math.abs(bal))}`; color = 'bg-red-50'; } else { badge = `Settled`; color = 'bg-gray-50'; } return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
Paid ${money.format(paidAmt)} ยท Share ${money.format(shareAmt)}
${badge}
`; }).join(''); // Settlements const settlements = calcSettlements(); if (settlements.length === 0) { setEl.innerHTML = '
โœ…
All settled up!
'; } else { setEl.innerHTML = settlements.map(s => { const toPerson = personById(s.to); const hasPayLink = toPerson && toPerson.paymentLink; let payBtn = ''; if (hasPayLink) { const url = getPayUrl(toPerson.paymentType, toPerson.paymentLink); payBtn = `${getPayIcon(toPerson.paymentType)} Pay`; } return `
${esc(personName(s.from).charAt(0).toUpperCase())}
${esc(personName(s.from))}
${esc(personName(s.to).charAt(0).toUpperCase())}
${esc(personName(s.to))}
${money.format(s.amount)} ${payBtn}
`; }).join(''); } } // โ”€โ”€ Event Handlers โ”€โ”€ // Add person $('btn-add-person').addEventListener('click', addPerson); $('new-person-name').addEventListener('keydown', e => { if (e.key === 'Enter') addPerson(); }); function addPerson() { const input = $('new-person-name'); const name = input.value.trim(); if (!name) return; if (state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { input.value = ''; return; } state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); input.value = ''; save(); render(); } // Delete person (delegated) $('people-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-person]'); if (!btn) return; const id = btn.dataset.deletePerson; const person = personById(id); if (!person) return; if (personHasExpenses(id)) { showDeleteToast(`${person.name} has expenses. Delete anyway?`, () => { // Remove person from all expense splitAmong arrays state.expenses.forEach(exp => { if (exp.splitAmong) { exp.splitAmong = exp.splitAmong.filter(pid => pid !== id); } }); // Remove expenses where this person is the payer state.expenses = state.expenses.filter(exp => exp.payerId !== id); state.people = state.people.filter(p => p.id !== id); save(); render(); }); } else { state.people = state.people.filter(p => p.id !== id); save(); render(); } }); // Add expense $('btn-add-expense').addEventListener('click', addExpense); $('expense-desc').addEventListener('keydown', e => { if (e.key === 'Enter') $('expense-amount').focus(); }); $('expense-amount').addEventListener('keydown', e => { if (e.key === 'Enter') addExpense(); }); function addExpense() { const desc = $('expense-desc').value.trim(); const amount = parseFloat($('expense-amount').value); const payerId = $('expense-payer').value; if (!desc) { $('expense-desc').focus(); return; } if (!amount || amount <= 0) { $('expense-amount').focus(); return; } if (!payerId) { $('expense-payer').focus(); return; } const checked = Array.from(document.querySelectorAll('.split-cb:checked')).map(cb => cb.value); if (checked.length === 0) { alert('Select at least one person to split among.'); return; } state.expenses.unshift({ id: uid(), desc, amount, payerId, splitAmong: checked, date: new Date().toISOString() }); $('expense-desc').value = ''; $('expense-amount').value = ''; save(); render(); } // Delete expense (delegated) $('expense-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-expense]'); if (!btn) return; const id = btn.dataset.deleteExpense; const expense = state.expenses.find(ex => ex.id === id); if (!expense) return; showDeleteToast(`Delete "${expense.desc}"?`, () => { state.expenses = state.expenses.filter(ex => ex.id !== id); save(); render(); }); }); // Toggle all split checkboxes $('btn-toggle-all').addEventListener('click', () => { const cbs = document.querySelectorAll('.split-cb'); const allChecked = Array.from(cbs).every(cb => cb.checked); cbs.forEach(cb => { cb.checked = !allChecked; }); $('btn-toggle-all').textContent = allChecked ? 'Select All' : 'Deselect All'; }); // Payment link modal let modalTarget = null; $('payment-links-list').addEventListener('click', e => { const btn = e.target.closest('[data-setlink]'); if (!btn) return; const id = btn.dataset.setlink; const person = personById(id); if (!person) return; modalTarget = id; $('modal-title').textContent = `Payment Link for ${person.name}`; $('modal-pay-type').value = person.paymentType || 'venmo'; $('modal-pay-link').value = person.paymentLink || ''; $('payment-modal').classList.remove('hidden'); }); $('modal-cancel').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-backdrop').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-save').addEventListener('click', () => { if (!modalTarget) return; const person = personById(modalTarget); if (!person) return; const type = $('modal-pay-type').value; const link = $('modal-pay-link').value.trim(); person.paymentType = link ? type : null; person.paymentLink = link || null; save(); render(); $('payment-modal').classList.add('hidden'); }); // โ”€โ”€ Init โ”€โ”€ load(); // Add Bosco and Ivan if not already present ['Bosco', 'Ivan'].forEach(name => { if (!state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); } }); save(); initTabs(); render(); })(); ) ? value : ' function personHasExpenses(personId) { return state.expenses.some(e => e.payerId === personId || (e.splitAmong && e.splitAmong.includes(personId))); } // โ”€โ”€ Calculations โ”€โ”€ function calcBalances() { const balances = {}; state.people.forEach(p => { balances[p.id] = 0; }); state.expenses.forEach(exp => { const amount = Number(exp.amount); const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = amount / split.length; // Payer paid full amount if (balances[exp.payerId] !== undefined) { balances[exp.payerId] += amount; } // Each person in split owes their share split.forEach(id => { if (balances[id] !== undefined) { balances[id] -= share; } }); }); return balances; } function calcSettlements() { const balances = calcBalances(); const debtors = []; const creditors = []; Object.entries(balances).forEach(([id, bal]) => { if (bal < -0.01) debtors.push({ id, amount: Math.abs(bal) }); else if (bal > 0.01) creditors.push({ id, amount: bal }); }); debtors.sort((a, b) => b.amount - a.amount); creditors.sort((a, b) => b.amount - a.amount); const settlements = []; let di = 0, ci = 0; while (di < debtors.length && ci < creditors.length) { const transfer = Math.min(debtors[di].amount, creditors[ci].amount); if (transfer > 0.01) { settlements.push({ from: debtors[di].id, to: creditors[ci].id, amount: transfer }); } debtors[di].amount -= transfer; creditors[ci].amount -= transfer; if (debtors[di].amount < 0.01) di++; if (creditors[ci].amount < 0.01) ci++; } return settlements; } // โ”€โ”€ DOM refs โ”€โ”€ const $ = id => document.getElementById(id); // โ”€โ”€ Tab Management โ”€โ”€ let activeTab = 'expenses'; function initTabs() { document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { activeTab = btn.dataset.tab; document.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none'); const tab = $('tab-' + activeTab); if (tab) tab.style.display = 'block'; updateTabStyles(); }); }); updateTabStyles(); $('tab-expenses').style.display = 'block'; } function updateTabStyles() { document.querySelectorAll('.tab-btn').forEach(btn => { if (btn.dataset.tab === activeTab) { btn.style.background = '#111827'; btn.style.color = '#fff'; } else { btn.style.background = 'transparent'; btn.style.color = '#6b7280'; } }); } // โ”€โ”€ Delete Confirmation Toast โ”€โ”€ let pendingDelete = null; function showDeleteToast(msg, onConfirm) { const toast = $('delete-toast'); const inner = toast.querySelector('div'); $('delete-toast-msg').textContent = msg; pendingDelete = onConfirm; toast.classList.remove('hidden'); inner.classList.remove('toast-out'); inner.classList.add('toast-in'); } function hideDeleteToast() { const toast = $('delete-toast'); const inner = toast.querySelector('div'); inner.classList.remove('toast-in'); inner.classList.add('toast-out'); setTimeout(() => { toast.classList.add('hidden'); inner.classList.remove('toast-out'); }, 200); pendingDelete = null; } $('delete-toast-confirm').addEventListener('click', () => { if (pendingDelete) pendingDelete(); hideDeleteToast(); }); $('delete-toast-cancel').addEventListener('click', hideDeleteToast); // โ”€โ”€ Render โ”€โ”€ function render() { renderPeopleList(); renderPaymentLinks(); renderPayerDropdown(); renderSplitCheckboxes(); renderExpenseList(); renderSettleTab(); } // โ”€โ”€ People โ”€โ”€ function renderPeopleList() { const el = $('people-list'); if (state.people.length === 0) { el.innerHTML = '
๐Ÿ‘ฅ
No people added yet.
Add someone to get started!
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentType)}: ${esc(p.paymentLink)}
` : ''}
`; }).join(''); } function renderPaymentLinks() { const el = $('payment-links-list'); if (state.people.length === 0) { el.innerHTML = '
Add people first
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentLink)}
` : '
No link set
'}
`; }).join(''); } function renderPayerDropdown() { const el = $('expense-payer'); const current = el.value; el.innerHTML = '' + state.people.map(p => ``).join(''); if (current && state.people.some(p => p.id === current)) el.value = current; } function renderSplitCheckboxes() { const el = $('split-checkboxes'); if (state.people.length === 0) { el.innerHTML = 'Add people first'; return; } el.innerHTML = state.people.map(p => `` ).join(''); } // โ”€โ”€ Expenses โ”€โ”€ function renderExpenseList() { const el = $('expense-list'); if (state.expenses.length === 0) { el.innerHTML = '
๐Ÿงพ
No expenses yet.
Add one above to get started!
'; return; } el.innerHTML = state.expenses.map(e => { const splitNames = (e.splitAmong || []).map(id => personName(id)).filter(n => n !== 'Unknown'); const splitText = splitNames.length === state.people.length ? 'everyone' : splitNames.join(', '); return `
${esc(e.desc)}
Paid by ${esc(personName(e.payerId))} ยท Split: ${esc(splitText)}
${money.format(e.amount)}
`; }).join(''); } // โ”€โ”€ Settle Tab โ”€โ”€ function renderSettleTab() { const total = state.expenses.reduce((s, e) => s + Number(e.amount), 0); $('total-amount').textContent = money.format(total); const balEl = $('balance-list'); const setEl = $('settlement-list'); if (state.people.length === 0 || state.expenses.length === 0) { balEl.innerHTML = '
๐Ÿ“Š
Add people and expenses to see balances.
'; setEl.innerHTML = ''; return; } const balances = calcBalances(); const paid = {}; state.people.forEach(p => { paid[p.id] = 0; }); state.expenses.forEach(e => { if (paid[e.payerId] !== undefined) paid[e.payerId] += Number(e.amount); }); // Compute each person's total share const shares = {}; state.people.forEach(p => { shares[p.id] = 0; }); state.expenses.forEach(exp => { const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = Number(exp.amount) / split.length; split.forEach(id => { if (shares[id] !== undefined) shares[id] += share; }); }); balEl.innerHTML = state.people.map(p => { const bal = balances[p.id]; const paidAmt = paid[p.id]; const shareAmt = shares[p.id]; let badge = ''; let color = ''; if (bal > 0.01) { badge = `+${money.format(bal)}`; color = 'bg-emerald-50'; } else if (bal < -0.01) { badge = `-${money.format(Math.abs(bal))}`; color = 'bg-red-50'; } else { badge = `Settled`; color = 'bg-gray-50'; } return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
Paid ${money.format(paidAmt)} ยท Share ${money.format(shareAmt)}
${badge}
`; }).join(''); // Settlements const settlements = calcSettlements(); if (settlements.length === 0) { setEl.innerHTML = '
โœ…
All settled up!
'; } else { setEl.innerHTML = settlements.map(s => { const toPerson = personById(s.to); const hasPayLink = toPerson && toPerson.paymentLink; let payBtn = ''; if (hasPayLink) { const url = getPayUrl(toPerson.paymentType, toPerson.paymentLink); payBtn = `${getPayIcon(toPerson.paymentType)} Pay`; } return `
${esc(personName(s.from).charAt(0).toUpperCase())}
${esc(personName(s.from))}
${esc(personName(s.to).charAt(0).toUpperCase())}
${esc(personName(s.to))}
${money.format(s.amount)} ${payBtn}
`; }).join(''); } } // โ”€โ”€ Event Handlers โ”€โ”€ // Add person $('btn-add-person').addEventListener('click', addPerson); $('new-person-name').addEventListener('keydown', e => { if (e.key === 'Enter') addPerson(); }); function addPerson() { const input = $('new-person-name'); const name = input.value.trim(); if (!name) return; if (state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { input.value = ''; return; } state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); input.value = ''; save(); render(); } // Delete person (delegated) $('people-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-person]'); if (!btn) return; const id = btn.dataset.deletePerson; const person = personById(id); if (!person) return; if (personHasExpenses(id)) { showDeleteToast(`${person.name} has expenses. Delete anyway?`, () => { // Remove person from all expense splitAmong arrays state.expenses.forEach(exp => { if (exp.splitAmong) { exp.splitAmong = exp.splitAmong.filter(pid => pid !== id); } }); // Remove expenses where this person is the payer state.expenses = state.expenses.filter(exp => exp.payerId !== id); state.people = state.people.filter(p => p.id !== id); save(); render(); }); } else { state.people = state.people.filter(p => p.id !== id); save(); render(); } }); // Add expense $('btn-add-expense').addEventListener('click', addExpense); $('expense-desc').addEventListener('keydown', e => { if (e.key === 'Enter') $('expense-amount').focus(); }); $('expense-amount').addEventListener('keydown', e => { if (e.key === 'Enter') addExpense(); }); function addExpense() { const desc = $('expense-desc').value.trim(); const amount = parseFloat($('expense-amount').value); const payerId = $('expense-payer').value; if (!desc) { $('expense-desc').focus(); return; } if (!amount || amount <= 0) { $('expense-amount').focus(); return; } if (!payerId) { $('expense-payer').focus(); return; } const checked = Array.from(document.querySelectorAll('.split-cb:checked')).map(cb => cb.value); if (checked.length === 0) { alert('Select at least one person to split among.'); return; } state.expenses.unshift({ id: uid(), desc, amount, payerId, splitAmong: checked, date: new Date().toISOString() }); $('expense-desc').value = ''; $('expense-amount').value = ''; save(); render(); } // Delete expense (delegated) $('expense-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-expense]'); if (!btn) return; const id = btn.dataset.deleteExpense; const expense = state.expenses.find(ex => ex.id === id); if (!expense) return; showDeleteToast(`Delete "${expense.desc}"?`, () => { state.expenses = state.expenses.filter(ex => ex.id !== id); save(); render(); }); }); // Toggle all split checkboxes $('btn-toggle-all').addEventListener('click', () => { const cbs = document.querySelectorAll('.split-cb'); const allChecked = Array.from(cbs).every(cb => cb.checked); cbs.forEach(cb => { cb.checked = !allChecked; }); $('btn-toggle-all').textContent = allChecked ? 'Select All' : 'Deselect All'; }); // Payment link modal let modalTarget = null; $('payment-links-list').addEventListener('click', e => { const btn = e.target.closest('[data-setlink]'); if (!btn) return; const id = btn.dataset.setlink; const person = personById(id); if (!person) return; modalTarget = id; $('modal-title').textContent = `Payment Link for ${person.name}`; $('modal-pay-type').value = person.paymentType || 'venmo'; $('modal-pay-link').value = person.paymentLink || ''; $('payment-modal').classList.remove('hidden'); }); $('modal-cancel').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-backdrop').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-save').addEventListener('click', () => { if (!modalTarget) return; const person = personById(modalTarget); if (!person) return; const type = $('modal-pay-type').value; const link = $('modal-pay-link').value.trim(); person.paymentType = link ? type : null; person.paymentLink = link || null; save(); render(); $('payment-modal').classList.add('hidden'); }); // โ”€โ”€ Init โ”€โ”€ load(); // Add Bosco and Ivan if not already present ['Bosco', 'Ivan'].forEach(name => { if (!state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); } }); save(); initTabs(); render(); })(); ) ? value : ' default: return value; } } function personHasExpenses(personId) { return state.expenses.some(e => e.payerId === personId || (e.splitAmong && e.splitAmong.includes(personId))); } // โ”€โ”€ Calculations โ”€โ”€ function calcBalances() { const balances = {}; state.people.forEach(p => { balances[p.id] = 0; }); state.expenses.forEach(exp => { const amount = Number(exp.amount); const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = amount / split.length; // Payer paid full amount if (balances[exp.payerId] !== undefined) { balances[exp.payerId] += amount; } // Each person in split owes their share split.forEach(id => { if (balances[id] !== undefined) { balances[id] -= share; } }); }); return balances; } function calcSettlements() { const balances = calcBalances(); const debtors = []; const creditors = []; Object.entries(balances).forEach(([id, bal]) => { if (bal < -0.01) debtors.push({ id, amount: Math.abs(bal) }); else if (bal > 0.01) creditors.push({ id, amount: bal }); }); debtors.sort((a, b) => b.amount - a.amount); creditors.sort((a, b) => b.amount - a.amount); const settlements = []; let di = 0, ci = 0; while (di < debtors.length && ci < creditors.length) { const transfer = Math.min(debtors[di].amount, creditors[ci].amount); if (transfer > 0.01) { settlements.push({ from: debtors[di].id, to: creditors[ci].id, amount: transfer }); } debtors[di].amount -= transfer; creditors[ci].amount -= transfer; if (debtors[di].amount < 0.01) di++; if (creditors[ci].amount < 0.01) ci++; } return settlements; } // โ”€โ”€ DOM refs โ”€โ”€ const $ = id => document.getElementById(id); // โ”€โ”€ Tab Management โ”€โ”€ let activeTab = 'expenses'; function initTabs() { document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { activeTab = btn.dataset.tab; document.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none'); const tab = $('tab-' + activeTab); if (tab) tab.style.display = 'block'; updateTabStyles(); }); }); updateTabStyles(); $('tab-expenses').style.display = 'block'; } function updateTabStyles() { document.querySelectorAll('.tab-btn').forEach(btn => { if (btn.dataset.tab === activeTab) { btn.style.background = '#111827'; btn.style.color = '#fff'; } else { btn.style.background = 'transparent'; btn.style.color = '#6b7280'; } }); } // โ”€โ”€ Delete Confirmation Toast โ”€โ”€ let pendingDelete = null; function showDeleteToast(msg, onConfirm) { const toast = $('delete-toast'); const inner = toast.querySelector('div'); $('delete-toast-msg').textContent = msg; pendingDelete = onConfirm; toast.classList.remove('hidden'); inner.classList.remove('toast-out'); inner.classList.add('toast-in'); } function hideDeleteToast() { const toast = $('delete-toast'); const inner = toast.querySelector('div'); inner.classList.remove('toast-in'); inner.classList.add('toast-out'); setTimeout(() => { toast.classList.add('hidden'); inner.classList.remove('toast-out'); }, 200); pendingDelete = null; } $('delete-toast-confirm').addEventListener('click', () => { if (pendingDelete) pendingDelete(); hideDeleteToast(); }); $('delete-toast-cancel').addEventListener('click', hideDeleteToast); // โ”€โ”€ Render โ”€โ”€ function render() { renderPeopleList(); renderPaymentLinks(); renderPayerDropdown(); renderSplitCheckboxes(); renderExpenseList(); renderSettleTab(); } // โ”€โ”€ People โ”€โ”€ function renderPeopleList() { const el = $('people-list'); if (state.people.length === 0) { el.innerHTML = '
๐Ÿ‘ฅ
No people added yet.
Add someone to get started!
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentType)}: ${esc(p.paymentLink)}
` : ''}
`; }).join(''); } function renderPaymentLinks() { const el = $('payment-links-list'); if (state.people.length === 0) { el.innerHTML = '
Add people first
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentLink)}
` : '
No link set
'}
`; }).join(''); } function renderPayerDropdown() { const el = $('expense-payer'); const current = el.value; el.innerHTML = '' + state.people.map(p => ``).join(''); if (current && state.people.some(p => p.id === current)) el.value = current; } function renderSplitCheckboxes() { const el = $('split-checkboxes'); if (state.people.length === 0) { el.innerHTML = 'Add people first'; return; } el.innerHTML = state.people.map(p => `` ).join(''); } // โ”€โ”€ Expenses โ”€โ”€ function renderExpenseList() { const el = $('expense-list'); if (state.expenses.length === 0) { el.innerHTML = '
๐Ÿงพ
No expenses yet.
Add one above to get started!
'; return; } el.innerHTML = state.expenses.map(e => { const splitNames = (e.splitAmong || []).map(id => personName(id)).filter(n => n !== 'Unknown'); const splitText = splitNames.length === state.people.length ? 'everyone' : splitNames.join(', '); return `
${esc(e.desc)}
Paid by ${esc(personName(e.payerId))} ยท Split: ${esc(splitText)}
${money.format(e.amount)}
`; }).join(''); } // โ”€โ”€ Settle Tab โ”€โ”€ function renderSettleTab() { const total = state.expenses.reduce((s, e) => s + Number(e.amount), 0); $('total-amount').textContent = money.format(total); const balEl = $('balance-list'); const setEl = $('settlement-list'); if (state.people.length === 0 || state.expenses.length === 0) { balEl.innerHTML = '
๐Ÿ“Š
Add people and expenses to see balances.
'; setEl.innerHTML = ''; return; } const balances = calcBalances(); const paid = {}; state.people.forEach(p => { paid[p.id] = 0; }); state.expenses.forEach(e => { if (paid[e.payerId] !== undefined) paid[e.payerId] += Number(e.amount); }); // Compute each person's total share const shares = {}; state.people.forEach(p => { shares[p.id] = 0; }); state.expenses.forEach(exp => { const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = Number(exp.amount) / split.length; split.forEach(id => { if (shares[id] !== undefined) shares[id] += share; }); }); balEl.innerHTML = state.people.map(p => { const bal = balances[p.id]; const paidAmt = paid[p.id]; const shareAmt = shares[p.id]; let badge = ''; let color = ''; if (bal > 0.01) { badge = `+${money.format(bal)}`; color = 'bg-emerald-50'; } else if (bal < -0.01) { badge = `-${money.format(Math.abs(bal))}`; color = 'bg-red-50'; } else { badge = `Settled`; color = 'bg-gray-50'; } return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
Paid ${money.format(paidAmt)} ยท Share ${money.format(shareAmt)}
${badge}
`; }).join(''); // Settlements const settlements = calcSettlements(); if (settlements.length === 0) { setEl.innerHTML = '
โœ…
All settled up!
'; } else { setEl.innerHTML = settlements.map(s => { const toPerson = personById(s.to); const hasPayLink = toPerson && toPerson.paymentLink; let payBtn = ''; if (hasPayLink) { const url = getPayUrl(toPerson.paymentType, toPerson.paymentLink); payBtn = `${getPayIcon(toPerson.paymentType)} Pay`; } return `
${esc(personName(s.from).charAt(0).toUpperCase())}
${esc(personName(s.from))}
${esc(personName(s.to).charAt(0).toUpperCase())}
${esc(personName(s.to))}
${money.format(s.amount)} ${payBtn}
`; }).join(''); } } // โ”€โ”€ Event Handlers โ”€โ”€ // Add person $('btn-add-person').addEventListener('click', addPerson); $('new-person-name').addEventListener('keydown', e => { if (e.key === 'Enter') addPerson(); }); function addPerson() { const input = $('new-person-name'); const name = input.value.trim(); if (!name) return; if (state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { input.value = ''; return; } state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); input.value = ''; save(); render(); } // Delete person (delegated) $('people-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-person]'); if (!btn) return; const id = btn.dataset.deletePerson; const person = personById(id); if (!person) return; if (personHasExpenses(id)) { showDeleteToast(`${person.name} has expenses. Delete anyway?`, () => { // Remove person from all expense splitAmong arrays state.expenses.forEach(exp => { if (exp.splitAmong) { exp.splitAmong = exp.splitAmong.filter(pid => pid !== id); } }); // Remove expenses where this person is the payer state.expenses = state.expenses.filter(exp => exp.payerId !== id); state.people = state.people.filter(p => p.id !== id); save(); render(); }); } else { state.people = state.people.filter(p => p.id !== id); save(); render(); } }); // Add expense $('btn-add-expense').addEventListener('click', addExpense); $('expense-desc').addEventListener('keydown', e => { if (e.key === 'Enter') $('expense-amount').focus(); }); $('expense-amount').addEventListener('keydown', e => { if (e.key === 'Enter') addExpense(); }); function addExpense() { const desc = $('expense-desc').value.trim(); const amount = parseFloat($('expense-amount').value); const payerId = $('expense-payer').value; if (!desc) { $('expense-desc').focus(); return; } if (!amount || amount <= 0) { $('expense-amount').focus(); return; } if (!payerId) { $('expense-payer').focus(); return; } const checked = Array.from(document.querySelectorAll('.split-cb:checked')).map(cb => cb.value); if (checked.length === 0) { alert('Select at least one person to split among.'); return; } state.expenses.unshift({ id: uid(), desc, amount, payerId, splitAmong: checked, date: new Date().toISOString() }); $('expense-desc').value = ''; $('expense-amount').value = ''; save(); render(); } // Delete expense (delegated) $('expense-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-expense]'); if (!btn) return; const id = btn.dataset.deleteExpense; const expense = state.expenses.find(ex => ex.id === id); if (!expense) return; showDeleteToast(`Delete "${expense.desc}"?`, () => { state.expenses = state.expenses.filter(ex => ex.id !== id); save(); render(); }); }); // Toggle all split checkboxes $('btn-toggle-all').addEventListener('click', () => { const cbs = document.querySelectorAll('.split-cb'); const allChecked = Array.from(cbs).every(cb => cb.checked); cbs.forEach(cb => { cb.checked = !allChecked; }); $('btn-toggle-all').textContent = allChecked ? 'Select All' : 'Deselect All'; }); // Payment link modal let modalTarget = null; $('payment-links-list').addEventListener('click', e => { const btn = e.target.closest('[data-setlink]'); if (!btn) return; const id = btn.dataset.setlink; const person = personById(id); if (!person) return; modalTarget = id; $('modal-title').textContent = `Payment Link for ${person.name}`; $('modal-pay-type').value = person.paymentType || 'venmo'; $('modal-pay-link').value = person.paymentLink || ''; $('payment-modal').classList.remove('hidden'); }); $('modal-cancel').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-backdrop').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-save').addEventListener('click', () => { if (!modalTarget) return; const person = personById(modalTarget); if (!person) return; const type = $('modal-pay-type').value; const link = $('modal-pay-link').value.trim(); person.paymentType = link ? type : null; person.paymentLink = link || null; save(); render(); $('payment-modal').classList.add('hidden'); }); // โ”€โ”€ Init โ”€โ”€ load(); // Add Bosco and Ivan if not already present ['Bosco', 'Ivan'].forEach(name => { if (!state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); } }); save(); initTabs(); render(); })(); + value}`; default: return value; } } function personHasExpenses(personId) { return state.expenses.some(e => e.payerId === personId || (e.splitAmong && e.splitAmong.includes(personId))); } // โ”€โ”€ Calculations โ”€โ”€ function calcBalances() { const balances = {}; state.people.forEach(p => { balances[p.id] = 0; }); state.expenses.forEach(exp => { const amount = Number(exp.amount); const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = amount / split.length; // Payer paid full amount if (balances[exp.payerId] !== undefined) { balances[exp.payerId] += amount; } // Each person in split owes their share split.forEach(id => { if (balances[id] !== undefined) { balances[id] -= share; } }); }); return balances; } function calcSettlements() { const balances = calcBalances(); const debtors = []; const creditors = []; Object.entries(balances).forEach(([id, bal]) => { if (bal < -0.01) debtors.push({ id, amount: Math.abs(bal) }); else if (bal > 0.01) creditors.push({ id, amount: bal }); }); debtors.sort((a, b) => b.amount - a.amount); creditors.sort((a, b) => b.amount - a.amount); const settlements = []; let di = 0, ci = 0; while (di < debtors.length && ci < creditors.length) { const transfer = Math.min(debtors[di].amount, creditors[ci].amount); if (transfer > 0.01) { settlements.push({ from: debtors[di].id, to: creditors[ci].id, amount: transfer }); } debtors[di].amount -= transfer; creditors[ci].amount -= transfer; if (debtors[di].amount < 0.01) di++; if (creditors[ci].amount < 0.01) ci++; } return settlements; } // โ”€โ”€ DOM refs โ”€โ”€ const $ = id => document.getElementById(id); // โ”€โ”€ Tab Management โ”€โ”€ let activeTab = 'expenses'; function initTabs() { document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { activeTab = btn.dataset.tab; document.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none'); const tab = $('tab-' + activeTab); if (tab) tab.style.display = 'block'; updateTabStyles(); }); }); updateTabStyles(); $('tab-expenses').style.display = 'block'; } function updateTabStyles() { document.querySelectorAll('.tab-btn').forEach(btn => { if (btn.dataset.tab === activeTab) { btn.style.background = '#111827'; btn.style.color = '#fff'; } else { btn.style.background = 'transparent'; btn.style.color = '#6b7280'; } }); } // โ”€โ”€ Delete Confirmation Toast โ”€โ”€ let pendingDelete = null; function showDeleteToast(msg, onConfirm) { const toast = $('delete-toast'); const inner = toast.querySelector('div'); $('delete-toast-msg').textContent = msg; pendingDelete = onConfirm; toast.classList.remove('hidden'); inner.classList.remove('toast-out'); inner.classList.add('toast-in'); } function hideDeleteToast() { const toast = $('delete-toast'); const inner = toast.querySelector('div'); inner.classList.remove('toast-in'); inner.classList.add('toast-out'); setTimeout(() => { toast.classList.add('hidden'); inner.classList.remove('toast-out'); }, 200); pendingDelete = null; } $('delete-toast-confirm').addEventListener('click', () => { if (pendingDelete) pendingDelete(); hideDeleteToast(); }); $('delete-toast-cancel').addEventListener('click', hideDeleteToast); // โ”€โ”€ Render โ”€โ”€ function render() { renderPeopleList(); renderPaymentLinks(); renderPayerDropdown(); renderSplitCheckboxes(); renderExpenseList(); renderSettleTab(); } // โ”€โ”€ People โ”€โ”€ function renderPeopleList() { const el = $('people-list'); if (state.people.length === 0) { el.innerHTML = '
๐Ÿ‘ฅ
No people added yet.
Add someone to get started!
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentType)}: ${esc(p.paymentLink)}
` : ''}
`; }).join(''); } function renderPaymentLinks() { const el = $('payment-links-list'); if (state.people.length === 0) { el.innerHTML = '
Add people first
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentLink)}
` : '
No link set
'}
`; }).join(''); } function renderPayerDropdown() { const el = $('expense-payer'); const current = el.value; el.innerHTML = '' + state.people.map(p => ``).join(''); if (current && state.people.some(p => p.id === current)) el.value = current; } function renderSplitCheckboxes() { const el = $('split-checkboxes'); if (state.people.length === 0) { el.innerHTML = 'Add people first'; return; } el.innerHTML = state.people.map(p => `` ).join(''); } // โ”€โ”€ Expenses โ”€โ”€ function renderExpenseList() { const el = $('expense-list'); if (state.expenses.length === 0) { el.innerHTML = '
๐Ÿงพ
No expenses yet.
Add one above to get started!
'; return; } el.innerHTML = state.expenses.map(e => { const splitNames = (e.splitAmong || []).map(id => personName(id)).filter(n => n !== 'Unknown'); const splitText = splitNames.length === state.people.length ? 'everyone' : splitNames.join(', '); return `
${esc(e.desc)}
Paid by ${esc(personName(e.payerId))} ยท Split: ${esc(splitText)}
${money.format(e.amount)}
`; }).join(''); } // โ”€โ”€ Settle Tab โ”€โ”€ function renderSettleTab() { const total = state.expenses.reduce((s, e) => s + Number(e.amount), 0); $('total-amount').textContent = money.format(total); const balEl = $('balance-list'); const setEl = $('settlement-list'); if (state.people.length === 0 || state.expenses.length === 0) { balEl.innerHTML = '
๐Ÿ“Š
Add people and expenses to see balances.
'; setEl.innerHTML = ''; return; } const balances = calcBalances(); const paid = {}; state.people.forEach(p => { paid[p.id] = 0; }); state.expenses.forEach(e => { if (paid[e.payerId] !== undefined) paid[e.payerId] += Number(e.amount); }); // Compute each person's total share const shares = {}; state.people.forEach(p => { shares[p.id] = 0; }); state.expenses.forEach(exp => { const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = Number(exp.amount) / split.length; split.forEach(id => { if (shares[id] !== undefined) shares[id] += share; }); }); balEl.innerHTML = state.people.map(p => { const bal = balances[p.id]; const paidAmt = paid[p.id]; const shareAmt = shares[p.id]; let badge = ''; let color = ''; if (bal > 0.01) { badge = `+${money.format(bal)}`; color = 'bg-emerald-50'; } else if (bal < -0.01) { badge = `-${money.format(Math.abs(bal))}`; color = 'bg-red-50'; } else { badge = `Settled`; color = 'bg-gray-50'; } return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
Paid ${money.format(paidAmt)} ยท Share ${money.format(shareAmt)}
${badge}
`; }).join(''); // Settlements const settlements = calcSettlements(); if (settlements.length === 0) { setEl.innerHTML = '
โœ…
All settled up!
'; } else { setEl.innerHTML = settlements.map(s => { const toPerson = personById(s.to); const hasPayLink = toPerson && toPerson.paymentLink; let payBtn = ''; if (hasPayLink) { const url = getPayUrl(toPerson.paymentType, toPerson.paymentLink); payBtn = `${getPayIcon(toPerson.paymentType)} Pay`; } return `
${esc(personName(s.from).charAt(0).toUpperCase())}
${esc(personName(s.from))}
${esc(personName(s.to).charAt(0).toUpperCase())}
${esc(personName(s.to))}
${money.format(s.amount)} ${payBtn}
`; }).join(''); } } // โ”€โ”€ Event Handlers โ”€โ”€ // Add person $('btn-add-person').addEventListener('click', addPerson); $('new-person-name').addEventListener('keydown', e => { if (e.key === 'Enter') addPerson(); }); function addPerson() { const input = $('new-person-name'); const name = input.value.trim(); if (!name) return; if (state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { input.value = ''; return; } state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); input.value = ''; save(); render(); } // Delete person (delegated) $('people-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-person]'); if (!btn) return; const id = btn.dataset.deletePerson; const person = personById(id); if (!person) return; if (personHasExpenses(id)) { showDeleteToast(`${person.name} has expenses. Delete anyway?`, () => { // Remove person from all expense splitAmong arrays state.expenses.forEach(exp => { if (exp.splitAmong) { exp.splitAmong = exp.splitAmong.filter(pid => pid !== id); } }); // Remove expenses where this person is the payer state.expenses = state.expenses.filter(exp => exp.payerId !== id); state.people = state.people.filter(p => p.id !== id); save(); render(); }); } else { state.people = state.people.filter(p => p.id !== id); save(); render(); } }); // Add expense $('btn-add-expense').addEventListener('click', addExpense); $('expense-desc').addEventListener('keydown', e => { if (e.key === 'Enter') $('expense-amount').focus(); }); $('expense-amount').addEventListener('keydown', e => { if (e.key === 'Enter') addExpense(); }); function addExpense() { const desc = $('expense-desc').value.trim(); const amount = parseFloat($('expense-amount').value); const payerId = $('expense-payer').value; if (!desc) { $('expense-desc').focus(); return; } if (!amount || amount <= 0) { $('expense-amount').focus(); return; } if (!payerId) { $('expense-payer').focus(); return; } const checked = Array.from(document.querySelectorAll('.split-cb:checked')).map(cb => cb.value); if (checked.length === 0) { alert('Select at least one person to split among.'); return; } state.expenses.unshift({ id: uid(), desc, amount, payerId, splitAmong: checked, date: new Date().toISOString() }); $('expense-desc').value = ''; $('expense-amount').value = ''; save(); render(); } // Delete expense (delegated) $('expense-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-expense]'); if (!btn) return; const id = btn.dataset.deleteExpense; const expense = state.expenses.find(ex => ex.id === id); if (!expense) return; showDeleteToast(`Delete "${expense.desc}"?`, () => { state.expenses = state.expenses.filter(ex => ex.id !== id); save(); render(); }); }); // Toggle all split checkboxes $('btn-toggle-all').addEventListener('click', () => { const cbs = document.querySelectorAll('.split-cb'); const allChecked = Array.from(cbs).every(cb => cb.checked); cbs.forEach(cb => { cb.checked = !allChecked; }); $('btn-toggle-all').textContent = allChecked ? 'Select All' : 'Deselect All'; }); // Payment link modal let modalTarget = null; $('payment-links-list').addEventListener('click', e => { const btn = e.target.closest('[data-setlink]'); if (!btn) return; const id = btn.dataset.setlink; const person = personById(id); if (!person) return; modalTarget = id; $('modal-title').textContent = `Payment Link for ${person.name}`; $('modal-pay-type').value = person.paymentType || 'venmo'; $('modal-pay-link').value = person.paymentLink || ''; $('payment-modal').classList.remove('hidden'); }); $('modal-cancel').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-backdrop').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-save').addEventListener('click', () => { if (!modalTarget) return; const person = personById(modalTarget); if (!person) return; const type = $('modal-pay-type').value; const link = $('modal-pay-link').value.trim(); person.paymentType = link ? type : null; person.paymentLink = link || null; save(); render(); $('payment-modal').classList.add('hidden'); }); // โ”€โ”€ Init โ”€โ”€ load(); // Add Bosco and Ivan if not already present ['Bosco', 'Ivan'].forEach(name => { if (!state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); } }); save(); initTabs(); render(); })(); + value}`; default: return value; } } function personHasExpenses(personId) { return state.expenses.some(e => e.payerId === personId || (e.splitAmong && e.splitAmong.includes(personId))); } // โ”€โ”€ Calculations โ”€โ”€ function calcBalances() { const balances = {}; state.people.forEach(p => { balances[p.id] = 0; }); state.expenses.forEach(exp => { const amount = Number(exp.amount); const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = amount / split.length; // Payer paid full amount if (balances[exp.payerId] !== undefined) { balances[exp.payerId] += amount; } // Each person in split owes their share split.forEach(id => { if (balances[id] !== undefined) { balances[id] -= share; } }); }); return balances; } function calcSettlements() { const balances = calcBalances(); const debtors = []; const creditors = []; Object.entries(balances).forEach(([id, bal]) => { if (bal < -0.01) debtors.push({ id, amount: Math.abs(bal) }); else if (bal > 0.01) creditors.push({ id, amount: bal }); }); debtors.sort((a, b) => b.amount - a.amount); creditors.sort((a, b) => b.amount - a.amount); const settlements = []; let di = 0, ci = 0; while (di < debtors.length && ci < creditors.length) { const transfer = Math.min(debtors[di].amount, creditors[ci].amount); if (transfer > 0.01) { settlements.push({ from: debtors[di].id, to: creditors[ci].id, amount: transfer }); } debtors[di].amount -= transfer; creditors[ci].amount -= transfer; if (debtors[di].amount < 0.01) di++; if (creditors[ci].amount < 0.01) ci++; } return settlements; } // โ”€โ”€ DOM refs โ”€โ”€ const $ = id => document.getElementById(id); // โ”€โ”€ Tab Management โ”€โ”€ let activeTab = 'expenses'; function initTabs() { document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { activeTab = btn.dataset.tab; document.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none'); const tab = $('tab-' + activeTab); if (tab) tab.style.display = 'block'; updateTabStyles(); }); }); updateTabStyles(); $('tab-expenses').style.display = 'block'; } function updateTabStyles() { document.querySelectorAll('.tab-btn').forEach(btn => { if (btn.dataset.tab === activeTab) { btn.style.background = '#111827'; btn.style.color = '#fff'; } else { btn.style.background = 'transparent'; btn.style.color = '#6b7280'; } }); } // โ”€โ”€ Delete Confirmation Toast โ”€โ”€ let pendingDelete = null; function showDeleteToast(msg, onConfirm) { const toast = $('delete-toast'); const inner = toast.querySelector('div'); $('delete-toast-msg').textContent = msg; pendingDelete = onConfirm; toast.classList.remove('hidden'); inner.classList.remove('toast-out'); inner.classList.add('toast-in'); } function hideDeleteToast() { const toast = $('delete-toast'); const inner = toast.querySelector('div'); inner.classList.remove('toast-in'); inner.classList.add('toast-out'); setTimeout(() => { toast.classList.add('hidden'); inner.classList.remove('toast-out'); }, 200); pendingDelete = null; } $('delete-toast-confirm').addEventListener('click', () => { if (pendingDelete) pendingDelete(); hideDeleteToast(); }); $('delete-toast-cancel').addEventListener('click', hideDeleteToast); // โ”€โ”€ Render โ”€โ”€ function render() { renderPeopleList(); renderPaymentLinks(); renderPayerDropdown(); renderSplitCheckboxes(); renderExpenseList(); renderSettleTab(); } // โ”€โ”€ People โ”€โ”€ function renderPeopleList() { const el = $('people-list'); if (state.people.length === 0) { el.innerHTML = '
๐Ÿ‘ฅ
No people added yet.
Add someone to get started!
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentType)}: ${esc(p.paymentLink)}
` : ''}
`; }).join(''); } function renderPaymentLinks() { const el = $('payment-links-list'); if (state.people.length === 0) { el.innerHTML = '
Add people first
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentLink)}
` : '
No link set
'}
`; }).join(''); } function renderPayerDropdown() { const el = $('expense-payer'); const current = el.value; el.innerHTML = '' + state.people.map(p => ``).join(''); if (current && state.people.some(p => p.id === current)) el.value = current; } function renderSplitCheckboxes() { const el = $('split-checkboxes'); if (state.people.length === 0) { el.innerHTML = 'Add people first'; return; } el.innerHTML = state.people.map(p => `` ).join(''); } // โ”€โ”€ Expenses โ”€โ”€ function renderExpenseList() { const el = $('expense-list'); if (state.expenses.length === 0) { el.innerHTML = '
๐Ÿงพ
No expenses yet.
Add one above to get started!
'; return; } el.innerHTML = state.expenses.map(e => { const splitNames = (e.splitAmong || []).map(id => personName(id)).filter(n => n !== 'Unknown'); const splitText = splitNames.length === state.people.length ? 'everyone' : splitNames.join(', '); return `
${esc(e.desc)}
Paid by ${esc(personName(e.payerId))} ยท Split: ${esc(splitText)}
${money.format(e.amount)}
`; }).join(''); } // โ”€โ”€ Settle Tab โ”€โ”€ function renderSettleTab() { const total = state.expenses.reduce((s, e) => s + Number(e.amount), 0); $('total-amount').textContent = money.format(total); const balEl = $('balance-list'); const setEl = $('settlement-list'); if (state.people.length === 0 || state.expenses.length === 0) { balEl.innerHTML = '
๐Ÿ“Š
Add people and expenses to see balances.
'; setEl.innerHTML = ''; return; } const balances = calcBalances(); const paid = {}; state.people.forEach(p => { paid[p.id] = 0; }); state.expenses.forEach(e => { if (paid[e.payerId] !== undefined) paid[e.payerId] += Number(e.amount); }); // Compute each person's total share const shares = {}; state.people.forEach(p => { shares[p.id] = 0; }); state.expenses.forEach(exp => { const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = Number(exp.amount) / split.length; split.forEach(id => { if (shares[id] !== undefined) shares[id] += share; }); }); balEl.innerHTML = state.people.map(p => { const bal = balances[p.id]; const paidAmt = paid[p.id]; const shareAmt = shares[p.id]; let badge = ''; let color = ''; if (bal > 0.01) { badge = `+${money.format(bal)}`; color = 'bg-emerald-50'; } else if (bal < -0.01) { badge = `-${money.format(Math.abs(bal))}`; color = 'bg-red-50'; } else { badge = `Settled`; color = 'bg-gray-50'; } return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
Paid ${money.format(paidAmt)} ยท Share ${money.format(shareAmt)}
${badge}
`; }).join(''); // Settlements const settlements = calcSettlements(); if (settlements.length === 0) { setEl.innerHTML = '
โœ…
All settled up!
'; } else { setEl.innerHTML = settlements.map(s => { const toPerson = personById(s.to); const hasPayLink = toPerson && toPerson.paymentLink; let payBtn = ''; if (hasPayLink) { const url = getPayUrl(toPerson.paymentType, toPerson.paymentLink); payBtn = `${getPayIcon(toPerson.paymentType)} Pay`; } return `
${esc(personName(s.from).charAt(0).toUpperCase())}
${esc(personName(s.from))}
${esc(personName(s.to).charAt(0).toUpperCase())}
${esc(personName(s.to))}
${money.format(s.amount)} ${payBtn}
`; }).join(''); } } // โ”€โ”€ Event Handlers โ”€โ”€ // Add person $('btn-add-person').addEventListener('click', addPerson); $('new-person-name').addEventListener('keydown', e => { if (e.key === 'Enter') addPerson(); }); function addPerson() { const input = $('new-person-name'); const name = input.value.trim(); if (!name) return; if (state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { input.value = ''; return; } state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); input.value = ''; save(); render(); } // Delete person (delegated) $('people-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-person]'); if (!btn) return; const id = btn.dataset.deletePerson; const person = personById(id); if (!person) return; if (personHasExpenses(id)) { showDeleteToast(`${person.name} has expenses. Delete anyway?`, () => { // Remove person from all expense splitAmong arrays state.expenses.forEach(exp => { if (exp.splitAmong) { exp.splitAmong = exp.splitAmong.filter(pid => pid !== id); } }); // Remove expenses where this person is the payer state.expenses = state.expenses.filter(exp => exp.payerId !== id); state.people = state.people.filter(p => p.id !== id); save(); render(); }); } else { state.people = state.people.filter(p => p.id !== id); save(); render(); } }); // Add expense $('btn-add-expense').addEventListener('click', addExpense); $('expense-desc').addEventListener('keydown', e => { if (e.key === 'Enter') $('expense-amount').focus(); }); $('expense-amount').addEventListener('keydown', e => { if (e.key === 'Enter') addExpense(); }); function addExpense() { const desc = $('expense-desc').value.trim(); const amount = parseFloat($('expense-amount').value); const payerId = $('expense-payer').value; if (!desc) { $('expense-desc').focus(); return; } if (!amount || amount <= 0) { $('expense-amount').focus(); return; } if (!payerId) { $('expense-payer').focus(); return; } const checked = Array.from(document.querySelectorAll('.split-cb:checked')).map(cb => cb.value); if (checked.length === 0) { alert('Select at least one person to split among.'); return; } state.expenses.unshift({ id: uid(), desc, amount, payerId, splitAmong: checked, date: new Date().toISOString() }); $('expense-desc').value = ''; $('expense-amount').value = ''; save(); render(); } // Delete expense (delegated) $('expense-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-expense]'); if (!btn) return; const id = btn.dataset.deleteExpense; const expense = state.expenses.find(ex => ex.id === id); if (!expense) return; showDeleteToast(`Delete "${expense.desc}"?`, () => { state.expenses = state.expenses.filter(ex => ex.id !== id); save(); render(); }); }); // Toggle all split checkboxes $('btn-toggle-all').addEventListener('click', () => { const cbs = document.querySelectorAll('.split-cb'); const allChecked = Array.from(cbs).every(cb => cb.checked); cbs.forEach(cb => { cb.checked = !allChecked; }); $('btn-toggle-all').textContent = allChecked ? 'Select All' : 'Deselect All'; }); // Payment link modal let modalTarget = null; $('payment-links-list').addEventListener('click', e => { const btn = e.target.closest('[data-setlink]'); if (!btn) return; const id = btn.dataset.setlink; const person = personById(id); if (!person) return; modalTarget = id; $('modal-title').textContent = `Payment Link for ${person.name}`; $('modal-pay-type').value = person.paymentType || 'venmo'; $('modal-pay-link').value = person.paymentLink || ''; $('payment-modal').classList.remove('hidden'); }); $('modal-cancel').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-backdrop').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-save').addEventListener('click', () => { if (!modalTarget) return; const person = personById(modalTarget); if (!person) return; const type = $('modal-pay-type').value; const link = $('modal-pay-link').value.trim(); person.paymentType = link ? type : null; person.paymentLink = link || null; save(); render(); $('payment-modal').classList.add('hidden'); }); // โ”€โ”€ Init โ”€โ”€ load(); // Add Bosco and Ivan if not already present ['Bosco', 'Ivan'].forEach(name => { if (!state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); } }); save(); initTabs(); render(); })(); ) ? value : ' default: return value; } } function personHasExpenses(personId) { return state.expenses.some(e => e.payerId === personId || (e.splitAmong && e.splitAmong.includes(personId))); } // โ”€โ”€ Calculations โ”€โ”€ function calcBalances() { const balances = {}; state.people.forEach(p => { balances[p.id] = 0; }); state.expenses.forEach(exp => { const amount = Number(exp.amount); const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = amount / split.length; // Payer paid full amount if (balances[exp.payerId] !== undefined) { balances[exp.payerId] += amount; } // Each person in split owes their share split.forEach(id => { if (balances[id] !== undefined) { balances[id] -= share; } }); }); return balances; } function calcSettlements() { const balances = calcBalances(); const debtors = []; const creditors = []; Object.entries(balances).forEach(([id, bal]) => { if (bal < -0.01) debtors.push({ id, amount: Math.abs(bal) }); else if (bal > 0.01) creditors.push({ id, amount: bal }); }); debtors.sort((a, b) => b.amount - a.amount); creditors.sort((a, b) => b.amount - a.amount); const settlements = []; let di = 0, ci = 0; while (di < debtors.length && ci < creditors.length) { const transfer = Math.min(debtors[di].amount, creditors[ci].amount); if (transfer > 0.01) { settlements.push({ from: debtors[di].id, to: creditors[ci].id, amount: transfer }); } debtors[di].amount -= transfer; creditors[ci].amount -= transfer; if (debtors[di].amount < 0.01) di++; if (creditors[ci].amount < 0.01) ci++; } return settlements; } // โ”€โ”€ DOM refs โ”€โ”€ const $ = id => document.getElementById(id); // โ”€โ”€ Tab Management โ”€โ”€ let activeTab = 'expenses'; function initTabs() { document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { activeTab = btn.dataset.tab; document.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none'); const tab = $('tab-' + activeTab); if (tab) tab.style.display = 'block'; updateTabStyles(); }); }); updateTabStyles(); $('tab-expenses').style.display = 'block'; } function updateTabStyles() { document.querySelectorAll('.tab-btn').forEach(btn => { if (btn.dataset.tab === activeTab) { btn.style.background = '#111827'; btn.style.color = '#fff'; } else { btn.style.background = 'transparent'; btn.style.color = '#6b7280'; } }); } // โ”€โ”€ Delete Confirmation Toast โ”€โ”€ let pendingDelete = null; function showDeleteToast(msg, onConfirm) { const toast = $('delete-toast'); const inner = toast.querySelector('div'); $('delete-toast-msg').textContent = msg; pendingDelete = onConfirm; toast.classList.remove('hidden'); inner.classList.remove('toast-out'); inner.classList.add('toast-in'); } function hideDeleteToast() { const toast = $('delete-toast'); const inner = toast.querySelector('div'); inner.classList.remove('toast-in'); inner.classList.add('toast-out'); setTimeout(() => { toast.classList.add('hidden'); inner.classList.remove('toast-out'); }, 200); pendingDelete = null; } $('delete-toast-confirm').addEventListener('click', () => { if (pendingDelete) pendingDelete(); hideDeleteToast(); }); $('delete-toast-cancel').addEventListener('click', hideDeleteToast); // โ”€โ”€ Render โ”€โ”€ function render() { renderPeopleList(); renderPaymentLinks(); renderPayerDropdown(); renderSplitCheckboxes(); renderExpenseList(); renderSettleTab(); } // โ”€โ”€ People โ”€โ”€ function renderPeopleList() { const el = $('people-list'); if (state.people.length === 0) { el.innerHTML = '
๐Ÿ‘ฅ
No people added yet.
Add someone to get started!
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentType)}: ${esc(p.paymentLink)}
` : ''}
`; }).join(''); } function renderPaymentLinks() { const el = $('payment-links-list'); if (state.people.length === 0) { el.innerHTML = '
Add people first
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentLink)}
` : '
No link set
'}
`; }).join(''); } function renderPayerDropdown() { const el = $('expense-payer'); const current = el.value; el.innerHTML = '' + state.people.map(p => ``).join(''); if (current && state.people.some(p => p.id === current)) el.value = current; } function renderSplitCheckboxes() { const el = $('split-checkboxes'); if (state.people.length === 0) { el.innerHTML = 'Add people first'; return; } el.innerHTML = state.people.map(p => `` ).join(''); } // โ”€โ”€ Expenses โ”€โ”€ function renderExpenseList() { const el = $('expense-list'); if (state.expenses.length === 0) { el.innerHTML = '
๐Ÿงพ
No expenses yet.
Add one above to get started!
'; return; } el.innerHTML = state.expenses.map(e => { const splitNames = (e.splitAmong || []).map(id => personName(id)).filter(n => n !== 'Unknown'); const splitText = splitNames.length === state.people.length ? 'everyone' : splitNames.join(', '); return `
${esc(e.desc)}
Paid by ${esc(personName(e.payerId))} ยท Split: ${esc(splitText)}
${money.format(e.amount)}
`; }).join(''); } // โ”€โ”€ Settle Tab โ”€โ”€ function renderSettleTab() { const total = state.expenses.reduce((s, e) => s + Number(e.amount), 0); $('total-amount').textContent = money.format(total); const balEl = $('balance-list'); const setEl = $('settlement-list'); if (state.people.length === 0 || state.expenses.length === 0) { balEl.innerHTML = '
๐Ÿ“Š
Add people and expenses to see balances.
'; setEl.innerHTML = ''; return; } const balances = calcBalances(); const paid = {}; state.people.forEach(p => { paid[p.id] = 0; }); state.expenses.forEach(e => { if (paid[e.payerId] !== undefined) paid[e.payerId] += Number(e.amount); }); // Compute each person's total share const shares = {}; state.people.forEach(p => { shares[p.id] = 0; }); state.expenses.forEach(exp => { const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = Number(exp.amount) / split.length; split.forEach(id => { if (shares[id] !== undefined) shares[id] += share; }); }); balEl.innerHTML = state.people.map(p => { const bal = balances[p.id]; const paidAmt = paid[p.id]; const shareAmt = shares[p.id]; let badge = ''; let color = ''; if (bal > 0.01) { badge = `+${money.format(bal)}`; color = 'bg-emerald-50'; } else if (bal < -0.01) { badge = `-${money.format(Math.abs(bal))}`; color = 'bg-red-50'; } else { badge = `Settled`; color = 'bg-gray-50'; } return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
Paid ${money.format(paidAmt)} ยท Share ${money.format(shareAmt)}
${badge}
`; }).join(''); // Settlements const settlements = calcSettlements(); if (settlements.length === 0) { setEl.innerHTML = '
โœ…
All settled up!
'; } else { setEl.innerHTML = settlements.map(s => { const toPerson = personById(s.to); const hasPayLink = toPerson && toPerson.paymentLink; let payBtn = ''; if (hasPayLink) { const url = getPayUrl(toPerson.paymentType, toPerson.paymentLink); payBtn = `${getPayIcon(toPerson.paymentType)} Pay`; } return `
${esc(personName(s.from).charAt(0).toUpperCase())}
${esc(personName(s.from))}
${esc(personName(s.to).charAt(0).toUpperCase())}
${esc(personName(s.to))}
${money.format(s.amount)} ${payBtn}
`; }).join(''); } } // โ”€โ”€ Event Handlers โ”€โ”€ // Add person $('btn-add-person').addEventListener('click', addPerson); $('new-person-name').addEventListener('keydown', e => { if (e.key === 'Enter') addPerson(); }); function addPerson() { const input = $('new-person-name'); const name = input.value.trim(); if (!name) return; if (state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { input.value = ''; return; } state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); input.value = ''; save(); render(); } // Delete person (delegated) $('people-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-person]'); if (!btn) return; const id = btn.dataset.deletePerson; const person = personById(id); if (!person) return; if (personHasExpenses(id)) { showDeleteToast(`${person.name} has expenses. Delete anyway?`, () => { // Remove person from all expense splitAmong arrays state.expenses.forEach(exp => { if (exp.splitAmong) { exp.splitAmong = exp.splitAmong.filter(pid => pid !== id); } }); // Remove expenses where this person is the payer state.expenses = state.expenses.filter(exp => exp.payerId !== id); state.people = state.people.filter(p => p.id !== id); save(); render(); }); } else { state.people = state.people.filter(p => p.id !== id); save(); render(); } }); // Add expense $('btn-add-expense').addEventListener('click', addExpense); $('expense-desc').addEventListener('keydown', e => { if (e.key === 'Enter') $('expense-amount').focus(); }); $('expense-amount').addEventListener('keydown', e => { if (e.key === 'Enter') addExpense(); }); function addExpense() { const desc = $('expense-desc').value.trim(); const amount = parseFloat($('expense-amount').value); const payerId = $('expense-payer').value; if (!desc) { $('expense-desc').focus(); return; } if (!amount || amount <= 0) { $('expense-amount').focus(); return; } if (!payerId) { $('expense-payer').focus(); return; } const checked = Array.from(document.querySelectorAll('.split-cb:checked')).map(cb => cb.value); if (checked.length === 0) { alert('Select at least one person to split among.'); return; } state.expenses.unshift({ id: uid(), desc, amount, payerId, splitAmong: checked, date: new Date().toISOString() }); $('expense-desc').value = ''; $('expense-amount').value = ''; save(); render(); } // Delete expense (delegated) $('expense-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-expense]'); if (!btn) return; const id = btn.dataset.deleteExpense; const expense = state.expenses.find(ex => ex.id === id); if (!expense) return; showDeleteToast(`Delete "${expense.desc}"?`, () => { state.expenses = state.expenses.filter(ex => ex.id !== id); save(); render(); }); }); // Toggle all split checkboxes $('btn-toggle-all').addEventListener('click', () => { const cbs = document.querySelectorAll('.split-cb'); const allChecked = Array.from(cbs).every(cb => cb.checked); cbs.forEach(cb => { cb.checked = !allChecked; }); $('btn-toggle-all').textContent = allChecked ? 'Select All' : 'Deselect All'; }); // Payment link modal let modalTarget = null; $('payment-links-list').addEventListener('click', e => { const btn = e.target.closest('[data-setlink]'); if (!btn) return; const id = btn.dataset.setlink; const person = personById(id); if (!person) return; modalTarget = id; $('modal-title').textContent = `Payment Link for ${person.name}`; $('modal-pay-type').value = person.paymentType || 'venmo'; $('modal-pay-link').value = person.paymentLink || ''; $('payment-modal').classList.remove('hidden'); }); $('modal-cancel').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-backdrop').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-save').addEventListener('click', () => { if (!modalTarget) return; const person = personById(modalTarget); if (!person) return; const type = $('modal-pay-type').value; const link = $('modal-pay-link').value.trim(); person.paymentType = link ? type : null; person.paymentLink = link || null; save(); render(); $('payment-modal').classList.add('hidden'); }); // โ”€โ”€ Init โ”€โ”€ load(); // Add Bosco and Ivan if not already present ['Bosco', 'Ivan'].forEach(name => { if (!state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); } }); save(); initTabs(); render(); })(); + value}`; default: return value; } } function personHasExpenses(personId) { return state.expenses.some(e => e.payerId === personId || (e.splitAmong && e.splitAmong.includes(personId))); } // โ”€โ”€ Calculations โ”€โ”€ function calcBalances() { const balances = {}; state.people.forEach(p => { balances[p.id] = 0; }); state.expenses.forEach(exp => { const amount = Number(exp.amount); const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = amount / split.length; // Payer paid full amount if (balances[exp.payerId] !== undefined) { balances[exp.payerId] += amount; } // Each person in split owes their share split.forEach(id => { if (balances[id] !== undefined) { balances[id] -= share; } }); }); return balances; } function calcSettlements() { const balances = calcBalances(); const debtors = []; const creditors = []; Object.entries(balances).forEach(([id, bal]) => { if (bal < -0.01) debtors.push({ id, amount: Math.abs(bal) }); else if (bal > 0.01) creditors.push({ id, amount: bal }); }); debtors.sort((a, b) => b.amount - a.amount); creditors.sort((a, b) => b.amount - a.amount); const settlements = []; let di = 0, ci = 0; while (di < debtors.length && ci < creditors.length) { const transfer = Math.min(debtors[di].amount, creditors[ci].amount); if (transfer > 0.01) { settlements.push({ from: debtors[di].id, to: creditors[ci].id, amount: transfer }); } debtors[di].amount -= transfer; creditors[ci].amount -= transfer; if (debtors[di].amount < 0.01) di++; if (creditors[ci].amount < 0.01) ci++; } return settlements; } // โ”€โ”€ DOM refs โ”€โ”€ const $ = id => document.getElementById(id); // โ”€โ”€ Tab Management โ”€โ”€ let activeTab = 'expenses'; function initTabs() { document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { activeTab = btn.dataset.tab; document.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none'); const tab = $('tab-' + activeTab); if (tab) tab.style.display = 'block'; updateTabStyles(); }); }); updateTabStyles(); $('tab-expenses').style.display = 'block'; } function updateTabStyles() { document.querySelectorAll('.tab-btn').forEach(btn => { if (btn.dataset.tab === activeTab) { btn.style.background = '#111827'; btn.style.color = '#fff'; } else { btn.style.background = 'transparent'; btn.style.color = '#6b7280'; } }); } // โ”€โ”€ Delete Confirmation Toast โ”€โ”€ let pendingDelete = null; function showDeleteToast(msg, onConfirm) { const toast = $('delete-toast'); const inner = toast.querySelector('div'); $('delete-toast-msg').textContent = msg; pendingDelete = onConfirm; toast.classList.remove('hidden'); inner.classList.remove('toast-out'); inner.classList.add('toast-in'); } function hideDeleteToast() { const toast = $('delete-toast'); const inner = toast.querySelector('div'); inner.classList.remove('toast-in'); inner.classList.add('toast-out'); setTimeout(() => { toast.classList.add('hidden'); inner.classList.remove('toast-out'); }, 200); pendingDelete = null; } $('delete-toast-confirm').addEventListener('click', () => { if (pendingDelete) pendingDelete(); hideDeleteToast(); }); $('delete-toast-cancel').addEventListener('click', hideDeleteToast); // โ”€โ”€ Render โ”€โ”€ function render() { renderPeopleList(); renderPaymentLinks(); renderPayerDropdown(); renderSplitCheckboxes(); renderExpenseList(); renderSettleTab(); } // โ”€โ”€ People โ”€โ”€ function renderPeopleList() { const el = $('people-list'); if (state.people.length === 0) { el.innerHTML = '
๐Ÿ‘ฅ
No people added yet.
Add someone to get started!
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentType)}: ${esc(p.paymentLink)}
` : ''}
`; }).join(''); } function renderPaymentLinks() { const el = $('payment-links-list'); if (state.people.length === 0) { el.innerHTML = '
Add people first
'; return; } el.innerHTML = state.people.map(p => { const hasLink = p.paymentLink; return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
${hasLink ? `
${getPayIcon(p.paymentType)} ${esc(p.paymentLink)}
` : '
No link set
'}
`; }).join(''); } function renderPayerDropdown() { const el = $('expense-payer'); const current = el.value; el.innerHTML = '' + state.people.map(p => ``).join(''); if (current && state.people.some(p => p.id === current)) el.value = current; } function renderSplitCheckboxes() { const el = $('split-checkboxes'); if (state.people.length === 0) { el.innerHTML = 'Add people first'; return; } el.innerHTML = state.people.map(p => `` ).join(''); } // โ”€โ”€ Expenses โ”€โ”€ function renderExpenseList() { const el = $('expense-list'); if (state.expenses.length === 0) { el.innerHTML = '
๐Ÿงพ
No expenses yet.
Add one above to get started!
'; return; } el.innerHTML = state.expenses.map(e => { const splitNames = (e.splitAmong || []).map(id => personName(id)).filter(n => n !== 'Unknown'); const splitText = splitNames.length === state.people.length ? 'everyone' : splitNames.join(', '); return `
${esc(e.desc)}
Paid by ${esc(personName(e.payerId))} ยท Split: ${esc(splitText)}
${money.format(e.amount)}
`; }).join(''); } // โ”€โ”€ Settle Tab โ”€โ”€ function renderSettleTab() { const total = state.expenses.reduce((s, e) => s + Number(e.amount), 0); $('total-amount').textContent = money.format(total); const balEl = $('balance-list'); const setEl = $('settlement-list'); if (state.people.length === 0 || state.expenses.length === 0) { balEl.innerHTML = '
๐Ÿ“Š
Add people and expenses to see balances.
'; setEl.innerHTML = ''; return; } const balances = calcBalances(); const paid = {}; state.people.forEach(p => { paid[p.id] = 0; }); state.expenses.forEach(e => { if (paid[e.payerId] !== undefined) paid[e.payerId] += Number(e.amount); }); // Compute each person's total share const shares = {}; state.people.forEach(p => { shares[p.id] = 0; }); state.expenses.forEach(exp => { const split = exp.splitAmong && exp.splitAmong.length > 0 ? exp.splitAmong.filter(id => personById(id)) : state.people.map(p => p.id); if (split.length === 0) return; const share = Number(exp.amount) / split.length; split.forEach(id => { if (shares[id] !== undefined) shares[id] += share; }); }); balEl.innerHTML = state.people.map(p => { const bal = balances[p.id]; const paidAmt = paid[p.id]; const shareAmt = shares[p.id]; let badge = ''; let color = ''; if (bal > 0.01) { badge = `+${money.format(bal)}`; color = 'bg-emerald-50'; } else if (bal < -0.01) { badge = `-${money.format(Math.abs(bal))}`; color = 'bg-red-50'; } else { badge = `Settled`; color = 'bg-gray-50'; } return `
${esc(p.name.charAt(0).toUpperCase())}
${esc(p.name)}
Paid ${money.format(paidAmt)} ยท Share ${money.format(shareAmt)}
${badge}
`; }).join(''); // Settlements const settlements = calcSettlements(); if (settlements.length === 0) { setEl.innerHTML = '
โœ…
All settled up!
'; } else { setEl.innerHTML = settlements.map(s => { const toPerson = personById(s.to); const hasPayLink = toPerson && toPerson.paymentLink; let payBtn = ''; if (hasPayLink) { const url = getPayUrl(toPerson.paymentType, toPerson.paymentLink); payBtn = `${getPayIcon(toPerson.paymentType)} Pay`; } return `
${esc(personName(s.from).charAt(0).toUpperCase())}
${esc(personName(s.from))}
${esc(personName(s.to).charAt(0).toUpperCase())}
${esc(personName(s.to))}
${money.format(s.amount)} ${payBtn}
`; }).join(''); } } // โ”€โ”€ Event Handlers โ”€โ”€ // Add person $('btn-add-person').addEventListener('click', addPerson); $('new-person-name').addEventListener('keydown', e => { if (e.key === 'Enter') addPerson(); }); function addPerson() { const input = $('new-person-name'); const name = input.value.trim(); if (!name) return; if (state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { input.value = ''; return; } state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); input.value = ''; save(); render(); } // Delete person (delegated) $('people-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-person]'); if (!btn) return; const id = btn.dataset.deletePerson; const person = personById(id); if (!person) return; if (personHasExpenses(id)) { showDeleteToast(`${person.name} has expenses. Delete anyway?`, () => { // Remove person from all expense splitAmong arrays state.expenses.forEach(exp => { if (exp.splitAmong) { exp.splitAmong = exp.splitAmong.filter(pid => pid !== id); } }); // Remove expenses where this person is the payer state.expenses = state.expenses.filter(exp => exp.payerId !== id); state.people = state.people.filter(p => p.id !== id); save(); render(); }); } else { state.people = state.people.filter(p => p.id !== id); save(); render(); } }); // Add expense $('btn-add-expense').addEventListener('click', addExpense); $('expense-desc').addEventListener('keydown', e => { if (e.key === 'Enter') $('expense-amount').focus(); }); $('expense-amount').addEventListener('keydown', e => { if (e.key === 'Enter') addExpense(); }); function addExpense() { const desc = $('expense-desc').value.trim(); const amount = parseFloat($('expense-amount').value); const payerId = $('expense-payer').value; if (!desc) { $('expense-desc').focus(); return; } if (!amount || amount <= 0) { $('expense-amount').focus(); return; } if (!payerId) { $('expense-payer').focus(); return; } const checked = Array.from(document.querySelectorAll('.split-cb:checked')).map(cb => cb.value); if (checked.length === 0) { alert('Select at least one person to split among.'); return; } state.expenses.unshift({ id: uid(), desc, amount, payerId, splitAmong: checked, date: new Date().toISOString() }); $('expense-desc').value = ''; $('expense-amount').value = ''; save(); render(); } // Delete expense (delegated) $('expense-list').addEventListener('click', e => { const btn = e.target.closest('[data-delete-expense]'); if (!btn) return; const id = btn.dataset.deleteExpense; const expense = state.expenses.find(ex => ex.id === id); if (!expense) return; showDeleteToast(`Delete "${expense.desc}"?`, () => { state.expenses = state.expenses.filter(ex => ex.id !== id); save(); render(); }); }); // Toggle all split checkboxes $('btn-toggle-all').addEventListener('click', () => { const cbs = document.querySelectorAll('.split-cb'); const allChecked = Array.from(cbs).every(cb => cb.checked); cbs.forEach(cb => { cb.checked = !allChecked; }); $('btn-toggle-all').textContent = allChecked ? 'Select All' : 'Deselect All'; }); // Payment link modal let modalTarget = null; $('payment-links-list').addEventListener('click', e => { const btn = e.target.closest('[data-setlink]'); if (!btn) return; const id = btn.dataset.setlink; const person = personById(id); if (!person) return; modalTarget = id; $('modal-title').textContent = `Payment Link for ${person.name}`; $('modal-pay-type').value = person.paymentType || 'venmo'; $('modal-pay-link').value = person.paymentLink || ''; $('payment-modal').classList.remove('hidden'); }); $('modal-cancel').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-backdrop').addEventListener('click', () => $('payment-modal').classList.add('hidden')); $('modal-save').addEventListener('click', () => { if (!modalTarget) return; const person = personById(modalTarget); if (!person) return; const type = $('modal-pay-type').value; const link = $('modal-pay-link').value.trim(); person.paymentType = link ? type : null; person.paymentLink = link || null; save(); render(); $('payment-modal').classList.add('hidden'); }); // โ”€โ”€ Init โ”€โ”€ load(); // Add Bosco and Ivan if not already present ['Bosco', 'Ivan'].forEach(name => { if (!state.people.some(p => p.name.toLowerCase() === name.toLowerCase())) { state.people.push({ id: uid(), name, paymentType: null, paymentLink: null }); } }); save(); initTabs(); render(); })();