import {
CATEGORIES,
addExpense,
calculateSummary,
deleteExpense,
filterExpenses,
getDateRange,
loadExpenses,
saveExpenses,
updateExpense,
} from './expense-model.mjs';
const euro = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });
const dateFormatter = new Intl.DateTimeFormat('es-ES', { day: 'numeric', month: 'short', year: 'numeric' });
const categorySymbols = {
Alimentación: 'A', Transporte: 'T', Hogar: 'H', Ocio: 'O', Salud: 'S', Educación: 'E', Otros: '•',
};
const form = document.querySelector('#expense-form');
const periodSelect = document.querySelector('#period-filter');
const categoryFilter = document.querySelector('#category-filter');
const searchInput = document.querySelector('#search-filter');
const feedback = document.querySelector('#feedback');
const formFeedback = document.querySelector('#form-feedback');
const formTitle = document.querySelector('#form-title');
const submitLabel = document.querySelector('#submit-label');
const cancelEdit = document.querySelector('#cancel-edit');
let expenses = loadExpenses();
let editingId = null;
function localToday() {
const now = new Date();
const offset = now.getTimezoneOffset() * 60_000;
return new Date(now.getTime() - offset).toISOString().slice(0, 10);
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[character]);
}
function showMessage(element, message, type = 'danger') {
element.textContent = message;
element.className = `alert alert-${type} py-2 small mb-3`;
element.hidden = false;
}
function clearMessage(element) {
element.hidden = true;
element.textContent = '';
}
function selectedRange() {
return getDateRange(periodSelect.value);
}
function formatDate(isoDate) {
const [year, month, day] = isoDate.split('-').map(Number);
return dateFormatter.format(new Date(year, month - 1, day));
}
function selectedPeriodName() {
return periodSelect.options[periodSelect.selectedIndex].text;
}
function renderSummary(summary) {
document.querySelector('#total-value').textContent = euro.format(summary.total);
document.querySelector('#expense-count').textContent = `${summary.count} ${summary.count === 1 ? 'gasto' : 'gastos'}`;
document.querySelector('#summary-period').textContent = selectedPeriodName();
const categories = Object.entries(summary.byCategory);
const list = document.querySelector('#category-breakdown');
const empty = document.querySelector('#category-empty');
if (categories.length === 0) {
list.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
list.innerHTML = categories.map(([category, amount]) => {
const percentage = summary.total === 0 ? 0 : Math.round((amount / summary.total) * 100);
const safeCategory = escapeHtml(category);
return `
${categorySymbols[category] ?? '•'}
${safeCategory}
${euro.format(amount)}
`;
}).join('');
}
function renderExpenses(summary) {
const range = selectedRange();
const visibleExpenses = filterExpenses(expenses, {
...range,
category: categoryFilter.value,
query: searchInput.value,
});
const body = document.querySelector('#expense-rows');
const empty = document.querySelector('#expenses-empty');
const table = document.querySelector('#expense-table-wrap');
document.querySelector('#movement-count').textContent = `${visibleExpenses.length} ${visibleExpenses.length === 1 ? 'movimiento' : 'movimientos'}`;
if (visibleExpenses.length === 0) {
body.innerHTML = '';
table.hidden = true;
empty.hidden = false;
const hasFilters = Boolean(categoryFilter.value || searchInput.value.trim());
empty.querySelector('strong').textContent = hasFilters ? 'No encontramos gastos con esos filtros' : 'Todavía no hay gastos en este período';
empty.querySelector('p').textContent = hasFilters
? 'Prueba a cambiar la búsqueda o la categoría.'
: 'Añade tu primer gasto y aquí tendrás todos tus movimientos a mano.';
return;
}
empty.hidden = true;
table.hidden = false;
body.innerHTML = visibleExpenses.map((expense) => `
| ${escapeHtml(formatDate(expense.date))} |
${escapeHtml(expense.description)} |
${categorySymbols[expense.category] ?? '•'}${escapeHtml(expense.category)} |
${euro.format(expense.amount)} |
|
`).join('');
}
function render() {
const summary = calculateSummary(expenses, selectedRange());
renderSummary(summary);
renderExpenses(summary);
}
function persist(nextExpenses, message) {
try {
saveExpenses(nextExpenses);
expenses = nextExpenses;
clearMessage(feedback);
render();
if (message) showMessage(feedback, message, 'success');
return true;
} catch (error) {
showMessage(formFeedback, error.message);
return false;
}
}
function resetForm() {
form.reset();
form.elements.date.value = localToday();
editingId = null;
formTitle.textContent = 'Añadir un gasto';
submitLabel.textContent = 'Guardar gasto';
cancelEdit.hidden = true;
clearMessage(formFeedback);
}
function beginEdit(id) {
const expense = expenses.find((item) => item.id === id);
if (!expense) return;
editingId = id;
form.elements.amount.value = expense.amount.toFixed(2);
form.elements.category.value = expense.category;
form.elements.date.value = expense.date;
form.elements.description.value = expense.description;
formTitle.textContent = 'Editar gasto';
submitLabel.textContent = 'Guardar cambios';
cancelEdit.hidden = false;
clearMessage(formFeedback);
document.querySelector('#entry-card').scrollIntoView({ behavior: 'smooth', block: 'center' });
form.elements.amount.focus({ preventScroll: true });
}
form.elements.date.value = localToday();
render();
form.addEventListener('submit', (event) => {
event.preventDefault();
clearMessage(formFeedback);
const input = {
amount: form.elements.amount.value,
category: form.elements.category.value,
date: form.elements.date.value,
description: form.elements.description.value,
};
try {
const next = editingId
? updateExpense(expenses, editingId, input)
: addExpense(expenses, input);
if (persist(next, editingId ? 'Gasto actualizado.' : 'Gasto guardado.')) resetForm();
} catch (error) {
showMessage(formFeedback, error.message);
}
});
cancelEdit.addEventListener('click', resetForm);
periodSelect.addEventListener('change', render);
categoryFilter.addEventListener('change', render);
searchInput.addEventListener('input', render);
document.querySelector('#clear-filters').addEventListener('click', () => {
categoryFilter.value = '';
searchInput.value = '';
render();
});
document.querySelector('#expense-rows').addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
if (!button) return;
const { action, id } = button.dataset;
if (action === 'edit') {
beginEdit(id);
return;
}
const expense = expenses.find((item) => item.id === id);
if (!expense || !window.confirm(`¿Eliminar el gasto «${expense.description}»?`)) return;
try {
const next = deleteExpense(expenses, id);
if (persist(next, 'Gasto eliminado.')) {
if (editingId === id) resetForm();
}
} catch (error) {
showMessage(feedback, error.message);
}
});