Virtual Backpacking Planner
Core Trip Details
Live Summaries
Budget Tracker
Packing Progress
Total Trip Duration
0 DaysTotal Pack Weight
0 lbsItinerary 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 = `
${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();
});
