Virtual Backpacking Planner

Virtual Backpacking Planner

Core Trip Details

Live Summaries

Budget Tracker

$0 / $0

Packing Progress

0 / 0 Items

Total Trip Duration

0 Days

Total Pack Weight

0 lbs

Itinerary Planner

Pack List Manager

Item Name Category Weight (lbs) Packed

Manage Pack Item Categories

Add or remove categories for the "Category" dropdown on the dashboard.

No stops added yet. Click "+ Add Stop" to start.

'; } state.stops.forEach(stop => { const card = document.createElement('div'); card.className = 'vbp-stop-card'; card.innerHTML = `
`; stopListContainer.appendChild(card); }); } /** * Renders the list of interactive pack list items. */ function renderPackList() { if (!packListContainer) return; packListContainer.innerHTML = ''; // Clear existing list state.packList.forEach(item => { const itemEl = document.createElement('div'); itemEl.className = 'vbp-pack-item'; itemEl.innerHTML = `
`; packListContainer.appendChild(itemEl); }); } /** * Renders the list in the Data Configuration tab. */ function renderCategoryConfig() { if (!categoryList) return; categoryList.innerHTML = ''; state.categories.forEach(cat => { categoryList.innerHTML += `
${cat}
`; }); } /** * Calculates and renders all summary statistics. */ function renderSummary() { // Budget const totalBudget = parseFloat(state.tripDetails.budget) || 0; const totalCost = state.stops.reduce((sum, s) => sum + (parseFloat(s.cost) || 0), 0); const budgetPercent = totalBudget > 0 ? (totalCost / totalBudget) * 100 : 0; budgetBar.style.width = `${Math.min(budgetPercent, 100)}%`; budgetText.textContent = `${formatCurrency(totalCost)} / ${formatCurrency(totalBudget)}`; budgetBar.classList.toggle('vbp-over-budget', totalCost > totalBudget); // Packing const totalItems = state.packList.length; const packedItems = state.packList.filter(item => item.packed).length; const packPercent = totalItems > 0 ? (packedItems / totalItems) * 100 : 0; packBar.style.width = `${packPercent}%`; packText.textContent = `${packedItems} / ${totalItems} Items`; packBar.classList.toggle('vbp-complete', packPercent === 100); // Duration const duration = getDaysDiff(state.tripDetails.startDate, state.tripDetails.endDate); summaryDuration.textContent = `${duration} Day${duration !== 1 ? 's' : ''}`; // Weight const totalWeight = state.packList.reduce((sum, item) => sum + (parseFloat(item.weight) || 0), 0); summaryWeight.textContent = `${totalWeight.toFixed(1)} lbs`; } /** * Central "update" function. Call this after any state change. */ function updateAll() { // Save state to localStorage localStorage.setItem('vbpState', JSON.stringify(state)); // Re-render all dynamic components renderStopList(); renderPackList(); renderCategoryConfig(); renderSummary(); } /** * Full re-render. Call when state *structure* changes (e.g., config). */ function fullReRender() { renderTripDetails(); updateAll(); } // --- EVENT LISTENERS --- // /** * Attaches listeners for tab navigation. */ function attachTabListeners() { if (!tabLinks || !nextTabBtn || !prevTabBtn) return; const tabIds = Array.from(tabLinks).map(link => link.dataset.tab); function showTab(tabId) { state.activeTab = tabId; tabContents.forEach(content => { content.classList.remove('vbp-active'); }); container.querySelector(`#${tabId}`).classList.add('vbp-active'); tabLinks.forEach(link => { link.classList.toggle('vbp-active', link.dataset.tab === tabId); }); // Update nav buttons const currentIndex = tabIds.indexOf(tabId); prevTabBtn.style.visibility = (currentIndex === 0) ? 'hidden' : 'visible'; nextTabBtn.style.visibility = (currentIndex === tabIds.length - 1) ? 'hidden' : 'visible'; } tabLinks.forEach(link => { link.addEventListener('click', () => showTab(link.dataset.tab)); }); nextTabBtn.addEventListener('click', () => { const currentIndex = tabIds.indexOf(state.activeTab); if (currentIndex < tabIds.length - 1) { showTab(tabIds[currentIndex + 1]); } }); prevTabBtn.addEventListener('click', () => { const currentIndex = tabIds.indexOf(state.activeTab); if (currentIndex > 0) { showTab(tabIds[currentIndex - 1]); } }); } /** * Attaches listeners for the Core Trip Details inputs. */ function attachDetailListeners() { const inputs = [ { el: tripNameInput, key: 'name' }, { el: startDateInput, key: 'startDate' }, { el: endDateInput, key: 'endDate' }, { el: totalBudgetInput, key: 'budget' } ]; inputs.forEach(item => { if (item.el) { item.el.addEventListener('input', (e) => { const value = (item.el.type === 'number') ? parseFloat(e.target.value) || 0 : e.target.value; state.tripDetails[item.key] = value; renderSummary(); // Only summary needs update localStorage.setItem('vbpState', JSON.stringify(state)); }); } }); } /** * Attaches listeners for adding/removing/editing stops and items. */ function attachListListeners() { // Add Stop if (addStopBtn) { addStopBtn.addEventListener('click', () => { state.stops.push({ id: `s${Date.now()}`, location: "New Stop", startDate: "", endDate: "", cost: 0 }); updateAll(); }); } // Add Pack Item if (addItemBtn) { addItemBtn.addEventListener('click', () => { state.packList.push({ id: `p${Date.now()}`, name: "New Item", category: state.categories[0], weight: 0, packed: false }); updateAll(); }); } // Event delegation for dynamic lists container.addEventListener('click', (e) => { // Remove Stop if (e.target.classList.contains('vbp-remove-stop-btn')) { const stopId = e.target.dataset.id; state.stops = state.stops.filter(s => s.id !== stopId); updateAll(); } // Remove Pack Item if (e.target.classList.contains('vbp-remove-item-btn')) { const itemId = e.target.dataset.id; state.packList = state.packList.filter(item => item.id !== itemId); updateAll(); } }); // Event delegation for inputs within lists container.addEventListener('input', (e) => { // Edit Stop Card if (e.target.classList.contains('vbp-stop-input')) { const stopId = e.target.dataset.id; const field = e.target.dataset.field; const stop = state.stops.find(s => s.id === stopId); if (stop) { stop[field] = (e.target.type === 'number') ? parseFloat(e.target.value) || 0 : e.target.value; renderSummary(); // Update budget localStorage.setItem('vbpState', JSON.stringify(state)); } } // Edit Pack List Item if (e.target.classList.contains('vbp-pack-input')) { const itemId = e.target.dataset.id; const field = e.target.dataset.field; const item = state.packList.find(i => i.id === itemId); if (item) { if (e.target.type === 'checkbox') { item[field] = e.target.checked; } else if (e.target.type === 'number') { item[field] = parseFloat(e.target.value) || 0; } else { item[field] = e.target.value; } renderSummary(); // Update weight and packing progress localStorage.setItem('vbpState', JSON.stringify(state)); } } }); } /** * Attaches listeners for the Data Configuration tab. */ function attachConfigListeners() { // Add Category if (addCategoryBtn) { addCategoryBtn.addEventListener('click', () => { const newName = newCategoryInput.value.trim(); if (newName && !state.categories.includes(newName)) { state.categories.push(newName); newCategoryInput.value = ''; updateAll(); // Update config list and pack list dropdowns } }); } // Remove Category (Event Delegation) if (categoryList) { categoryList.addEventListener('click', (e) => { if (e.target.classList.contains('vbp-remove-category-btn')) { const catName = e.target.dataset.name; state.categories = state.categories.filter(c => c !== catName); // Set any items using this category to the first available const fallback = state.categories[0] || ""; state.packList.forEach(item => { if (item.category === catName) item.category = fallback; }); updateAll(); } }); } } /** * Attaches listener for the PDF Download button. */ function attachPdfListener() { if (downloadPdfBtn) { downloadPdfBtn.addEventListener('click', () => { // Ensure jsPDF and autoTable are loaded if (typeof jspdf === 'undefined' || !jspdf.jsPDF) { alert('Error: PDF library (jsPDF) not loaded.'); return; } if (typeof jspdf.jsPDF.autoTable !== 'function') { alert('Error: PDF library (autoTable) not loaded.'); return; } const { jsPDF } = jspdf; const doc = new jsPDF(); const details = state.tripDetails; // 1. Title doc.setFontSize(22); doc.text(details.name || "Backpacking Trip Plan", 105, 20, { align: 'center' }); // 2. Core Details & Summary doc.setFontSize(12); doc.text(`Dates: ${details.startDate || 'N/A'} to ${details.endDate || 'N/A'}`, 14, 35); doc.text(`Duration: ${summaryDuration.textContent}`, 14, 42); doc.text(`Total Budget: ${formatCurrency(details.budget)}`, 14, 49); doc.text(`Total Pack Weight: ${summaryWeight.textContent}`, 105, 35); doc.text(`Packing Progress: ${packText.textContent}`, 105, 42); doc.text(`Budget Used: ${budgetText.textContent}`, 105, 49); // 3. Itinerary Table const itineraryBody = state.stops.map(s => [ s.location, s.startDate, s.endDate, formatCurrency(s.cost) ]); doc.autoTable({ head: [['Location / Stop', 'Start Date', 'End Date', 'Est. Cost']], body: itineraryBody, startY: 60, theme: 'grid', headStyles: { fillColor: [0, 123, 255] }, didDrawPage: function(data) { doc.setFontSize(18); doc.text('Trip Itinerary', 14, data.settings.startY - 10); } }); // 4. Pack List Table const packListBody = state.packList.map(item => [ item.name, item.category, `${item.weight.toFixed(1)} lbs`, item.packed ? 'YES' : 'NO' ]); doc.autoTable({ head: [['Item', 'Category', 'Weight', 'Packed']], body: packListBody, startY: doc.lastAutoTable.finalY + 20, theme: 'grid', headStyles: { fillColor: [40, 167, 69] }, // Green header didDrawPage: function(data) { doc.setFontSize(18); doc.text('Packing List', 14, data.settings.startY - 10); } }); // 5. Save const safeFileName = (details.name || 'backpacking-plan').replace(/[^a-z0-9]/gi, '_').toLowerCase(); doc.save(`${safeFileName}.pdf`); }); } } /** * Loads state from localStorage, if it exists. */ function loadState() { const savedState = localStorage.getItem('vbpState'); if (savedState) { const parsedState = JSON.parse(savedState); // Merge saved state with default state state = { ...state, ...parsedState }; } } // --- INITIALIZATION --- // /** * Initializes the entire application. */ function init() { loadState(); // Load saved data attachTabListeners(); attachDetailListeners(); attachListListeners(); attachConfigListeners(); attachPdfListener(); fullReRender(); // Initial render of all components } init(); });