Aprende a crear un Savings Goal Tracker con Bootstrap 5 y JavaScript. Registra aportes, muestra porcentaje alcanzado, historial y estimación del tiempo restante según el promedio de ahorro
En este tutorial aprenderás a crear tu propio Savings Goal Tracker, una aplicación web sencilla que te permitirá:
- Establecer una meta de ahorro.
- Registrar aportes periódicos.
- Visualizar el porcentaje alcanzado.
- Consultar un historial de aportes.
- Recibir una estimación del tiempo que te falta para llegar a la meta según tu promedio de ahorro.
Tecnologías utilizadas
- HTML5 → estructura de la app.
- Bootstrap 5 → estilos rápidos y diseño responsivo.
- CSS → personalización adicional.
- JavaScript → lógica del ahorro, cálculos y manejo de LocalStorage.
Paso 1: Estructura de archivos
Necesitamos tres archivos principales:
index.html
style.css
script.js
Paso 2: Código HTML (index.html)
Crea un archivo llamado index.html y coloca el siguiente contenido:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Savings Goal Tracker</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="style.css" rel="stylesheet">
</head>
<body>
<div class="container py-4">
<div class="main-container p-4">
<div class="text-center mb-4">
<h1 class="display-4 fw-bold text-primary mb-2">
<i class="fas fa-piggy-bank me-3"></i>Savings Goal Tracker
</h1>
<p class="lead text-muted">Establece tus metas y alcanza tus sueños financieros</p>
</div>
<!-- Goal Setup Form -->
<div id="goalForm" class="mb-4">
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="card-title mb-3">
<i class="fas fa-target me-2 text-primary"></i>Establecer Meta de Ahorro
</h5>
<form id="savingsGoalForm">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Nombre de la Meta</label>
<input type="text" class="form-control" id="goalName" placeholder="Ej: Vacaciones en Europa" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Meta ($)</label>
<input type="number" class="form-control" id="goalAmount" placeholder="5000" min="1" required>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-plus me-2"></i>Crear Meta
</button>
</form>
</div>
</div>
</div>
<!-- Current Goal Display -->
<div id="currentGoal" style="display: none;">
<div class="goal-card">
<div class="row align-items-center">
<div class="col-md-8">
<h3 id="goalTitle" class="mb-3"></h3>
<div class="row">
<div class="col-6">
<h5>Ahorrado</h5>
<h2 id="currentAmount">$0</h2>
</div>
<div class="col-6">
<h5>Meta</h5>
<h2 id="targetAmount">$0</h2>
</div>
</div>
<div class="mt-3">
<small id="timeEstimate" class="opacity-75"></small>
</div>
</div>
<div class="col-md-4 text-center">
<div class="progress-circle mx-auto" id="progressCircle">
<div class="progress-inner">
<span id="progressPercent">0%</span>
</div>
</div>
</div>
</div>
</div>
<!-- Add Contribution Form -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<h5 class="card-title mb-3">
<i class="fas fa-plus-circle me-2 text-success"></i>Agregar Aporte
</h5>
<form id="contributionForm">
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label fw-semibold">Cantidad ($)</label>
<input type="number" class="form-control" id="contributionAmount" placeholder="100" min="0.01" step="0.01" required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold"> </label>
<button type="submit" class="btn btn-success w-100">
<i class="fas fa-plus me-2"></i>Agregar
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Statistics -->
<div class="row mb-4">
<div class="col-md-4">
<div class="stats-card">
<i class="fas fa-chart-line icon-lg text-primary"></i>
<h6 class="fw-semibold">Promedio Mensual</h6>
<h4 id="monthlyAverage">$0</h4>
</div>
</div>
<div class="col-md-4">
<div class="stats-card">
<i class="fas fa-calendar-alt icon-lg text-success"></i>
<h6 class="fw-semibold">Total Aportes</h6>
<h4 id="totalContributions">0</h4>
</div>
</div>
<div class="col-md-4">
<div class="stats-card">
<i class="fas fa-percentage icon-lg text-info"></i>
<h6 class="fw-semibold">Progreso</h6>
<h4 id="progressStats">0%</h4>
</div>
</div>
</div>
<!-- Contribution History -->
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0">
<i class="fas fa-history me-2 text-primary"></i>Historial de Aportes
</h5>
<div class="btn-group">
<button class="btn btn-outline-warning btn-sm" onclick="editGoal()">
<i class="fas fa-edit me-1"></i>Editar Meta
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="clearAllContributions()">
<i class="fas fa-broom me-1"></i>Limpiar Aportes
</button>
<button class="btn btn-outline-danger btn-sm" onclick="resetGoal()">
<i class="fas fa-redo me-1"></i>Nueva Meta
</button>
</div>
</div>
<div id="contributionHistory">
<div class="empty-state">
<i class="fas fa-coins"></i>
<h6>No hay aportes aún</h6>
<p>Agrega tu primer aporte para comenzar a ver tu progreso</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Edit Goal Modal -->
<div class="modal fade" id="editGoalModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-edit me-2 text-warning"></i>Editar Meta de Ahorro
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editGoalForm">
<div class="mb-3">
<label class="form-label fw-semibold">Nombre de la Meta</label>
<input type="text" class="form-control" id="editGoalName" required>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Meta ($)</label>
<input type="number" class="form-control" id="editGoalAmount" min="1" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-warning" id="saveGoalChanges">
<i class="fas fa-save me-2"></i>Guardar Cambios
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="script.js"></script>
</body>
</html>
Paso 3: Estilos personalizados (style.css)
Crea un archivo style.css con los siguientes estilos básicos:
body {
box-sizing: border-box;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.main-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
margin: 20px auto;
max-width: 800px;
}
.goal-card {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
border-radius: 15px;
color: white;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 10px 30px rgba(79, 172, 254, 0.3);
}
.progress-circle {
width: 120px;
height: 120px;
border-radius: 50%;
background: conic-gradient(#00f2fe 0deg, #00f2fe var(--progress), rgba(255,255,255,0.3) var(--progress), rgba(255,255,255,0.3) 360deg);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.progress-inner {
width: 90px;
height: 90px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2rem;
}
.contribution-card {
background: white;
border-radius: 15px;
padding: 1.5rem;
margin-bottom: 1rem;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08);
border-left: 4px solid #4facfe;
transition: transform 0.2s ease;
}
.contribution-card:hover {
transform: translateY(-2px);
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 10px;
padding: 12px 30px;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.btn-success {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
border: none;
border-radius: 10px;
padding: 12px 30px;
font-weight: 600;
}
.form-control {
border-radius: 10px;
border: 2px solid #e9ecef;
padding: 12px 15px;
transition: all 0.3s ease;
}
.form-control:focus {
border-color: #4facfe;
box-shadow: 0 0 0 0.2rem rgba(79, 172, 254, 0.25);
}
.stats-card {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
border-radius: 15px;
padding: 1.5rem;
text-align: center;
margin-bottom: 1rem;
}
.icon-lg {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.empty-state {
text-align: center;
padding: 3rem;
color: #6c757d;
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
Paso 4: Lógica en JavaScript (script.js)
Ahora la parte más importante. Crea un archivo script.js y agrega este código:
// variables globales
let goal = null;
let contributions = [];
// Initializar la app
function initApp() {
loadData();
initEventListeners();
}
function initEventListeners() {
document.getElementById('savingsGoalForm').addEventListener('submit', (e) => {
e.preventDefault();
createGoal();
});
document.getElementById('contributionForm').addEventListener('submit', (e) => {
e.preventDefault();
addContribution();
});
document.getElementById('saveGoalChanges').addEventListener('click', (e) => {
e.preventDefault();
saveGoalEdit();
});
}
function createGoal() {
const name = document.getElementById('goalName').value;
const amount = parseFloat(document.getElementById('goalAmount').value);
goal = {
name: name,
target: amount,
current: 0,
createdAt: new Date()
};
contributions = [];
saveData();
updateDisplay();
showGoal();
// Reset formulario
document.getElementById('savingsGoalForm').reset();
}
function addContribution() {
const amount = parseFloat(document.getElementById('contributionAmount').value);
const contribution = {
amount: amount,
date: new Date(),
id: Date.now()
};
contributions.unshift(contribution);
goal.current += amount;
saveData();
updateDisplay();
renderContributionHistory();
// Reset formulario
document.getElementById('contributionForm').reset();
showSuccessMessage(amount);
}
function showSuccessMessage(amount) {
const button = document.querySelector('#contributionForm button');
const originalText = button.innerHTML;
button.innerHTML = '<i class="fas fa-check me-2"></i>¡Agregado!';
button.classList.add('btn-outline-success');
button.classList.remove('btn-success');
setTimeout(() => {
button.innerHTML = originalText;
button.classList.remove('btn-outline-success');
button.classList.add('btn-success');
}, 1500);
}
function updateDisplay() {
if (!goal) return;
const percentage = Math.min((goal.current / goal.target) * 100, 100);
const progressDegrees = (percentage / 100) * 360;
// Actualiza info de la meta
document.getElementById('goalTitle').textContent = goal.name;
document.getElementById('currentAmount').textContent = `$${goal.current.toLocaleString()}`;
document.getElementById('targetAmount').textContent = `$${goal.target.toLocaleString()}`;
// actualiza el circulo de progreso
const progressCircle = document.getElementById('progressCircle');
progressCircle.style.setProperty('--progress', `${progressDegrees}deg`);
document.getElementById('progressPercent').textContent = `${Math.round(percentage)}%`;
// actualiza estadisticas
updateStatistics();
updateTimeEstimate();
}
function updateStatistics() {
const totalContributions = contributions.length;
const percentage = Math.min((goal.current / goal.target) * 100, 100);
// Calcula el promedio mensual
let monthlyAverage = 0;
if (contributions.length > 0) {
const firstContribution = new Date(contributions[contributions.length - 1].date);
const now = new Date();
const monthsDiff = Math.max(1, (now - firstContribution) / (1000 * 60 * 60 * 24 * 30));
monthlyAverage = goal.current / monthsDiff;
}
document.getElementById('monthlyAverage').textContent = `$${Math.round(monthlyAverage).toLocaleString()}`;
document.getElementById('totalContributions').textContent = totalContributions;
document.getElementById('progressStats').textContent = `${Math.round(percentage)}%`;
}
function updateTimeEstimate() {
if (contributions.length < 2) {
document.getElementById('timeEstimate').textContent = 'Agrega más aportes para ver estimación de tiempo';
return;
}
const remaining = goal.target - goal.current;
if (remaining <= 0) {
document.getElementById('timeEstimate').textContent = ' ¡Meta alcanzada! ¡Felicitaciones!';
return;
}
// Calcular promedio de contribuciones 3 anteriores
const recentContributions = contributions.slice(0, Math.min(3, contributions.length));
const avgContribution = recentContributions.reduce((sum, c) => sum + c.amount, 0) / recentContributions.length;
if (avgContribution > 0) {
const contributionsNeeded = Math.ceil(remaining / avgContribution);
const timeEstimate = contributionsNeeded === 1 ?
'Con el próximo aporte alcanzarás tu meta' :
`Aproximadamente ${contributionsNeeded} aportes más para alcanzar tu meta`;
document.getElementById('timeEstimate').textContent = timeEstimate;
} else {
document.getElementById('timeEstimate').textContent = 'Continúa ahorrando para ver estimación';
}
}
function renderContributionHistory() {
const historyContainer = document.getElementById('contributionHistory');
if (contributions.length === 0) {
historyContainer.innerHTML = `
<div class="empty-state">
<i class="fas fa-coins"></i>
<h6>No hay aportes aún</h6>
<p>Agrega tu primer aporte para comenzar a ver tu progreso</p>
</div>
`;
return;
}
historyContainer.innerHTML = contributions.map(contribution => `
<div class="contribution-card fade-in">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1 text-success fw-semibold">+$${contribution.amount.toLocaleString()}</h6>
<small class="text-muted">
<i class="fas fa-calendar me-1"></i>
${new Date(contribution.date).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</small>
</div>
<div class="text-end d-flex align-items-center gap-2">
<button class="btn btn-outline-danger btn-sm" onclick="deleteContribution(${contribution.id})" title="Eliminar aporte">
<i class="fas fa-trash"></i>
</button>
<i class="fas fa-arrow-up text-success fs-4"></i>
</div>
</div>
</div>
`).join('');
}
function showGoal() {
document.getElementById('goalForm').style.display = 'none';
document.getElementById('currentGoal').style.display = 'block';
renderContributionHistory();
}
function deleteContribution(contributionId) {
const contribution = contributions.find(c => c.id === contributionId);
if (!contribution) return;
const confirmMessage = `¿Estás seguro de que quieres eliminar el aporte de $${contribution.amount.toLocaleString()}?`;
if (confirm(confirmMessage)) {
contributions = contributions.filter(c => c.id !== contributionId);
goal.current -= contribution.amount;
if (goal.current < 0) {
goal.current = 0;
}
saveData();
updateDisplay();
renderContributionHistory();
showDeleteMessage();
}
}
function showDeleteMessage() {
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show position-fixed';
alertDiv.style.cssText = 'top: 20px; right: 20px; z-index: 1050; min-width: 300px;';
alertDiv.innerHTML = `
<i class="fas fa-check-circle me-2"></i>
Aporte eliminado correctamente
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alertDiv);
// Auto cierre despues de 3 segundos
setTimeout(() => {
if (alertDiv.parentNode) {
alertDiv.remove();
}
}, 3000);
}
function editGoal() {
if (!goal) return;
document.getElementById('editGoalName').value = goal.name;
document.getElementById('editGoalAmount').value = goal.target;
const modal = new bootstrap.Modal(document.getElementById('editGoalModal'));
modal.show();
}
function saveGoalEdit() {
const newName = document.getElementById('editGoalName').value;
const newAmount = parseFloat(document.getElementById('editGoalAmount').value);
if (!newName || !newAmount || newAmount <= 0) {
alert('Por favor, completa todos los campos correctamente.');
return;
}
goal.name = newName;
goal.target = newAmount;
saveData();
updateDisplay();
// Cerrar modal
const modal = bootstrap.Modal.getInstance(document.getElementById('editGoalModal'));
modal.hide();
// Mostra mensaje
showEditMessage();
}
function showEditMessage() {
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-info alert-dismissible fade show position-fixed';
alertDiv.style.cssText = 'top: 20px; right: 20px; z-index: 1050; min-width: 300px;';
alertDiv.innerHTML = `
<i class="fas fa-edit me-2"></i>
Meta actualizada correctamente
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alertDiv);
setTimeout(() => {
if (alertDiv.parentNode) {
alertDiv.remove();
}
}, 3000);
}
function clearAllContributions() {
if (contributions.length === 0) {
alert('No hay aportes para eliminar.');
return;
}
const confirmMessage = `¿Estás seguro de que quieres eliminar todos los ${contributions.length} aportes? Esta acción no se puede deshacer.`;
if (confirm(confirmMessage)) {
contributions = [];
goal.current = 0;
saveData();
updateDisplay();
renderContributionHistory();
showClearMessage();
}
}
function showClearMessage() {
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-warning alert-dismissible fade show position-fixed';
alertDiv.style.cssText = 'top: 20px; right: 20px; z-index: 1050; min-width: 300px;';
alertDiv.innerHTML = `
<i class="fas fa-broom me-2"></i>
Todos los aportes han sido eliminados
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alertDiv);
setTimeout(() => {
if (alertDiv.parentNode) {
alertDiv.remove();
}
}, 3000);
}
function resetGoal() {
if (confirm('¿Estás seguro de que quieres crear una nueva meta? Se perderá el progreso actual.')) {
goal = null;
contributions = [];
saveData();
document.getElementById('goalForm').style.display = 'block';
document.getElementById('currentGoal').style.display = 'none';
}
}
function saveData() {
localStorage.setItem('savingsGoal', JSON.stringify(goal));
localStorage.setItem('savingsContributions', JSON.stringify(contributions));
}
function loadData() {
const savedGoal = localStorage.getItem('savingsGoal');
const savedContributions = localStorage.getItem('savingsContributions');
if (savedGoal) {
goal = JSON.parse(savedGoal);
}
if (savedContributions) {
contributions = JSON.parse(savedContributions);
}
if (goal) {
showGoal();
updateDisplay();
}
}
// Initializar la app cuando el DOM cargue
document.addEventListener('DOMContentLoaded', () => {
initApp();
});
Paso 5: Prueba tu aplicación
Ahora abre index.html en tu navegador y prueba el funcionamiento:
- Establece una meta (ejemplo: $1000).
- Agrega aportes periódicos (ejemplo: $100 cada semana).
- Observa cómo la barra de progreso aumenta.
- Consulta el historial y la estimación de tiempo restante.
Conclusión
Con este Savings Goal Tracker ya tienes una aplicación funcional que te ayudará a gestionar mejor tus ahorros. La clave está en el uso de LocalStorage, que permite mantener tus datos guardados entre sesiones.
Puedes mejorar esta base con características adicionales como:
- Exportar historial a CSV/Excel.
- Permitir múltiples metas de ahorro.
- Añadir gráficos con Chart.js para visualizar aportes.
Eres libre de modificarlo y crear una versión más completa usando bases de datos como MySQL y un lenguaje de servidor como PHP.
Comentarios