diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d6ce54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules/ +package-lock.json + +# Configuration (contains API keys) +config.local.js + +# Build artifacts +dist/ +build/ +*.min.js +*.min.css + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Testing +coverage/ +.nyc_output/ + +# Temporary files +tmp/ +temp/ +*.tmp diff --git a/README.md b/README.md index 5f70f21..69b766e 100644 --- a/README.md +++ b/README.md @@ -1 +1,199 @@ -# Gov_Travel_App \ No newline at end of file +# Government Travel Cost Estimator 🍁 + +A comprehensive web application for calculating official Canadian government travel costs per the National Joint Council (NJC) Travel Directive. + +## Features + +βœ… **237+ City Database** - Complete database of Canadian cities with validation +βœ… **Real-Time Flight Search** - Amadeus API integration for live flight availability +βœ… **Multi-Currency Support** - EUR, AUD, CAD, USD with automatic conversion +βœ… **City-Specific Per Diem Rates** - Based on NJC Appendix C (January 2026) +βœ… **Accommodation Rates** - Based on NJC Appendix D (January 2026) +βœ… **Business Class Eligibility** - Automatic determination for 9+ hour flights +βœ… **Private Accommodation Options** - Support for staying with friends/family +βœ… **Export Functionality** - Export calculations as JSON +βœ… **Print Support** - Print-friendly format for expense reports + +## Quick Start + +### Option 1: Open Directly in Browser + +1. Clone or download this repository +2. Open `index.html` in your web browser +3. Start calculating travel costs! + +### Option 2: Using a Local Server (Recommended) + +```bash +# Using Python 3 +python -m http.server 8000 + +# Using Node.js +npx http-server + +# Then open http://localhost:8000 in your browser +``` + +## Usage + +1. **Enter Travel Details** + - Select departure city from 237+ Canadian cities + - Select destination city + - Choose departure and return dates + - Select preferred currency (CAD, USD, EUR, or AUD) + +2. **Accommodation Options** + - Check "Private Accommodation" if staying with friends/family + - Enter custom rate if applicable (optional) + +3. **Optional Features** + - Enable "Search Real-Time Flight Availability" for flight data + - Requires Amadeus API configuration (see below) + +4. **Calculate & Review** + - Click "Calculate Travel Costs" + - Review detailed breakdown including: + - Accommodation costs + - Per diem (meals & incidentals) + - Flight information (if enabled) + - Currency conversion details + - Total estimated cost + +5. **Export Results** + - Print summary for expense reports + - Export as JSON for record-keeping + +## NJC Rate Tiers + +The application uses three tiers of rates based on city size and cost of living: + +- **Tier 1** - Major cities (Toronto, Montreal, Vancouver, etc.) +- **Tier 2** - Large cities and regional centers +- **Tier 3** - Smaller cities and towns (default rate) + +### Sample Rates (January 2026) + +| City | Per Diem | Accommodation | Tier | +|------|----------|---------------|------| +| Toronto | $95.00 | $204.00 | 1 | +| Vancouver | $98.00 | $223.00 | 1 | +| Calgary | $92.00 | $195.00 | 1 | +| Ottawa | $94.00 | $205.00 | 1 | +| Halifax | $87.00 | $178.00 | 1 | + +### Private Accommodation Rate + +When staying at private residences: **$50.00/night** + +### Per Diem Breakdown + +Daily per diem is allocated as follows: +- Breakfast: 20% +- Lunch: 30% +- Dinner: 40% +- Incidentals: 10% + +## Business Class Eligibility + +Business class is automatically approved for flights **9 hours or longer**, per NJC Travel Directive guidelines. + +The application estimates flight durations and determines eligibility: +- βœ“ Eligible: Flights β‰₯ 9 hours +- βœ— Not Eligible: Flights < 9 hours + +## Amadeus API Configuration (Optional) + +To enable real-time flight search: + +1. **Get API Credentials** + - Visit [Amadeus for Developers](https://developers.amadeus.com/) + - Sign up for a free account + - Create a new app + - Copy your API Key and API Secret + +2. **Configure the Application** + - Open `config.js` + - Add your credentials: + ```javascript + const CONFIG = { + amadeus: { + apiKey: 'YOUR_API_KEY_HERE', + apiSecret: 'YOUR_API_SECRET_HERE', + environment: 'test' + } + }; + ``` + +3. **Restart the Application** + - Flight search will now use real-time data + +**Note:** The application works without API configuration using estimated flight data. + +## Currency Support + +Supported currencies with automatic conversion: +- πŸ‡¨πŸ‡¦ CAD - Canadian Dollar +- πŸ‡ΊπŸ‡Έ USD - US Dollar +- πŸ‡ͺπŸ‡Ί EUR - Euro +- πŸ‡¦πŸ‡Ί AUD - Australian Dollar + +Exchange rates are based on January 2026 values. In production, integrate with a real-time currency API for current rates. + +## Project Structure + +``` +Gov_Travel_App/ +β”œβ”€β”€ index.html # Main application interface +β”œβ”€β”€ styles.css # Application styling +β”œβ”€β”€ cityData.js # 237+ city database +β”œβ”€β”€ njcRates.js # NJC per diem and accommodation rates +β”œβ”€β”€ currencyConverter.js # Multi-currency conversion +β”œβ”€β”€ flightSearch.js # Amadeus API integration +β”œβ”€β”€ calculator.js # Cost calculation engine +β”œβ”€β”€ app.js # Main application logic +β”œβ”€β”€ config.js # API configuration +└── README.md # This file +``` + +## Data Sources + +All rates are based on official NJC Travel Directive documentation: +- **Appendix C** - City-specific per diem rates +- **Appendix D** - Accommodation rates +- **Effective Date** - January 2026 + +## Browser Compatibility + +- βœ… Chrome 90+ +- βœ… Firefox 88+ +- βœ… Safari 14+ +- βœ… Edge 90+ + +## Contributing + +Contributions are welcome! Please ensure: +- Code follows existing style +- City data remains accurate +- NJC rates are kept up-to-date +- All features are tested + +## License + +This tool is provided as-is for calculating government travel costs per NJC Travel Directive guidelines. + +## Disclaimer + +This calculator is a tool to estimate travel costs based on NJC Travel Directive rates. Always verify calculations and consult your department's travel policy for specific requirements and approvals. + +## Support + +For issues or questions: +1. Check the documentation above +2. Review NJC Travel Directive guidelines +3. Open an issue on GitHub + +--- + +**Version:** 1.0.0 +**Last Updated:** January 2026 +**Data Source:** NJC Travel Directive Appendices C & D \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..718e298 --- /dev/null +++ b/app.js @@ -0,0 +1,356 @@ +// Main Application Script +// Handles UI interactions and form submission + +document.addEventListener('DOMContentLoaded', function() { + initializeApp(); +}); + +function initializeApp() { + // Populate city datalist + populateCityList(); + + // Set up form event listeners + setupFormListeners(); + + // Set minimum date to today + const today = new Date().toISOString().split('T')[0]; + document.getElementById('departureDate').setAttribute('min', today); + document.getElementById('returnDate').setAttribute('min', today); + + console.log('Government Travel Cost Estimator initialized'); + console.log(`Loaded ${CITIES.length} cities`); +} + +function populateCityList() { + const datalist = document.getElementById('cityList'); + CITIES.forEach(city => { + const option = document.createElement('option'); + option.value = city.name; + option.textContent = `${city.name}, ${city.province}`; + datalist.appendChild(option); + }); +} + +function setupFormListeners() { + const form = document.getElementById('travelForm'); + const privateAccomCheckbox = document.getElementById('privateAccommodation'); + const departureCityInput = document.getElementById('departureCity'); + const destinationCityInput = document.getElementById('destinationCity'); + const departureDateInput = document.getElementById('departureDate'); + const returnDateInput = document.getElementById('returnDate'); + + // Form submission + form.addEventListener('submit', handleFormSubmit); + + // Private accommodation toggle + privateAccomCheckbox.addEventListener('change', function() { + const details = document.getElementById('privateAccommodationDetails'); + details.style.display = this.checked ? 'block' : 'none'; + }); + + // City validation + departureCityInput.addEventListener('blur', function() { + validateCityInput(this, 'departureCityError'); + }); + + destinationCityInput.addEventListener('blur', function() { + validateCityInput(this, 'destinationCityError'); + }); + + // Date validation + departureDateInput.addEventListener('change', function() { + if (returnDateInput.value) { + validateDateRange(); + } + }); + + returnDateInput.addEventListener('change', function() { + if (departureDateInput.value) { + validateDateRange(); + } + }); +} + +function validateCityInput(input, errorId) { + const errorSpan = document.getElementById(errorId); + const cityName = input.value.trim(); + + if (!cityName) { + errorSpan.textContent = ''; + return true; + } + + if (!validateCity(cityName)) { + errorSpan.textContent = `"${cityName}" is not in our database. Please select a valid Canadian city.`; + input.setCustomValidity('Invalid city'); + return false; + } + + errorSpan.textContent = ''; + input.setCustomValidity(''); + return true; +} + +function validateDateRange() { + const departureDateInput = document.getElementById('departureDate'); + const returnDateInput = document.getElementById('returnDate'); + + const validation = validateDates(departureDateInput.value, returnDateInput.value); + + if (!validation.valid) { + returnDateInput.setCustomValidity(validation.error); + alert(validation.error); + return false; + } + + returnDateInput.setCustomValidity(''); + return true; +} + +async function handleFormSubmit(event) { + event.preventDefault(); + + // Clear previous results and errors + document.getElementById('results').style.display = 'none'; + document.getElementById('error').style.display = 'none'; + + // Get form values + const departureCity = document.getElementById('departureCity').value.trim(); + const destinationCity = document.getElementById('destinationCity').value.trim(); + const departureDate = document.getElementById('departureDate').value; + const returnDate = document.getElementById('returnDate').value; + const currency = document.getElementById('currency').value; + const privateAccommodation = document.getElementById('privateAccommodation').checked; + const privateAccommodationRate = parseFloat(document.getElementById('privateAccommodationRate').value) || null; + const searchFlightsOption = document.getElementById('searchFlights').checked; + + // Validate cities + if (!validateCityInput(document.getElementById('departureCity'), 'departureCityError') || + !validateCityInput(document.getElementById('destinationCity'), 'destinationCityError')) { + return; + } + + // Validate dates + if (!validateDateRange()) { + return; + } + + try { + // Show loading state + showLoading(); + + // Calculate costs + const results = await calculateTravelCosts({ + departureCity, + destinationCity, + departureDate, + returnDate, + currency, + privateAccommodation, + privateAccommodationRate, + searchFlights: searchFlightsOption + }); + + // Display results + displayResults(results); + + // Store results for export + window.lastCalculation = results; + + } catch (error) { + console.error('Calculation error:', error); + showError(error.message); + } finally { + hideLoading(); + } +} + +function showLoading() { + const submitBtn = document.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.textContent = 'Calculating...'; +} + +function hideLoading() { + const submitBtn = document.querySelector('button[type="submit"]'); + submitBtn.disabled = false; + submitBtn.textContent = 'Calculate Travel Costs'; +} + +function displayResults(results) { + const resultsDiv = document.getElementById('results'); + const { tripDetails, costs, totals } = results; + + // Accommodation section + const accommodationDetails = document.getElementById('accommodationDetails'); + accommodationDetails.innerHTML = ` +
+ Type: + ${costs.accommodation.type} Accommodation +
+
+ Nightly Rate: + ${formatCurrency(costs.accommodation.nightlyRate, 'CAD')} +
+
+ Number of Nights: + ${costs.accommodation.nights} +
+
+ Total Accommodation: + ${formatCurrency(costs.accommodation.total, 'CAD')} +
+ `; + + // Per Diem section + const perDiemDetails = document.getElementById('perDiemDetails'); + perDiemDetails.innerHTML = ` +
+ Daily Per Diem Rate: + ${formatCurrency(costs.perDiem.dailyRate, 'CAD')} +
+
+ Number of Days: + ${tripDetails.duration.days} +
+
+ Daily Breakdown:
+ Breakfast: ${formatCurrency(costs.perDiem.breakdown.breakfast, 'CAD')}
+ Lunch: ${formatCurrency(costs.perDiem.breakdown.lunch, 'CAD')}
+ Dinner: ${formatCurrency(costs.perDiem.breakdown.dinner, 'CAD')}
+ Incidentals: ${formatCurrency(costs.perDiem.breakdown.incidentals, 'CAD')} +
+
+ Total Per Diem: + ${formatCurrency(costs.perDiem.total, 'CAD')} +
+ `; + + // Flight information + if (costs.flights) { + const flightInfo = document.getElementById('flightInfo'); + const flightDetails = document.getElementById('flightDetails'); + flightInfo.style.display = 'block'; + + const flight = costs.flights.details; + + flightDetails.innerHTML = ` + ${flight.businessClassEligible ? + '
βœ“ Business Class Eligible
' + flight.message + '
' : + '
' + flight.message + '
'} + +

Outbound Flight

+
+ Route: + ${flight.outbound.departure.city} (${flight.outbound.departure.airport}) β†’ ${flight.outbound.arrival.city} (${flight.outbound.arrival.airport}) +
+
+ Departure: + ${flight.outbound.departure.date} at ${flight.outbound.departure.time} +
+
+ Duration: + ${flight.outbound.durationFormatted} ${flight.outbound.stops > 0 ? `(${flight.outbound.stops} stop)` : '(direct)'} +
+ +

Return Flight

+
+ Route: + ${flight.return.departure.city} (${flight.return.departure.airport}) β†’ ${flight.return.arrival.city} (${flight.return.arrival.airport}) +
+
+ Departure: + ${flight.return.departure.date} at ${flight.return.departure.time} +
+
+ Duration: + ${flight.return.durationFormatted} ${flight.return.stops > 0 ? `(${flight.return.stops} stop)` : '(direct)'} +
+ +

Pricing

+
+ Economy Class: + ${formatCurrency(flight.pricing.economy, 'CAD')} +
+
+ Business Class: + ${formatCurrency(flight.pricing.business, 'CAD')} +
+
+ Selected (${flight.businessClassEligible ? 'Business' : 'Economy'}): + ${formatCurrency(costs.flights.total, 'CAD')} +
+ +

${flight.note}

+ `; + } else { + document.getElementById('flightInfo').style.display = 'none'; + } + + // Currency conversion info + if (totals.conversion) { + const currencyInfo = document.getElementById('currencyInfo'); + const currencyDetails = document.getElementById('currencyDetails'); + currencyInfo.style.display = 'block'; + + currencyDetails.innerHTML = ` +
+ Exchange Rate: + ${totals.conversion.formatted.rate} +
+
+ Original (CAD): + ${totals.conversion.formatted.original} +
+
+ Converted (${totals.selectedCurrency}): + ${totals.conversion.formatted.converted} +
+ `; + } else { + document.getElementById('currencyInfo').style.display = 'none'; + } + + // Total cost + const totalCostDiv = document.getElementById('totalCost'); + totalCostDiv.innerHTML = ` +
${formatCurrency(totals.selectedCurrencyTotal, totals.selectedCurrency)}
+ ${totals.selectedCurrency !== 'CAD' ? + `
(${formatCurrency(totals.cadTotal, 'CAD')} CAD)
` : + ''} + `; + + // Show results + resultsDiv.style.display = 'block'; + resultsDiv.scrollIntoView({ behavior: 'smooth' }); +} + +function showError(message) { + const errorDiv = document.getElementById('error'); + errorDiv.textContent = `Error: ${message}`; + errorDiv.style.display = 'block'; + errorDiv.scrollIntoView({ behavior: 'smooth' }); +} + +// Export results as JSON +function exportToJSON() { + if (!window.lastCalculation) { + alert('No calculation results to export'); + return; + } + + const dataStr = JSON.stringify(window.lastCalculation, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + + const link = document.createElement('a'); + link.href = url; + link.download = `travel-estimate-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +// Make exportToJSON available globally +window.exportToJSON = exportToJSON; diff --git a/calculator.js b/calculator.js new file mode 100644 index 0000000..4b47bb0 --- /dev/null +++ b/calculator.js @@ -0,0 +1,200 @@ +// Travel Cost Calculator Module +// Main calculation engine for government travel costs + +// Calculate number of days between two dates +function calculateDays(startDate, endDate) { + const start = new Date(startDate); + const end = new Date(endDate); + const diffTime = Math.abs(end - start); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return diffDays; +} + +// Calculate number of nights +function calculateNights(startDate, endDate) { + const days = calculateDays(startDate, endDate); + return Math.max(0, days); +} + +// Calculate total travel costs +async function calculateTravelCosts(params) { + const { + departureCity, + destinationCity, + departureDate, + returnDate, + currency, + privateAccommodation, + privateAccommodationRate, + searchFlights + } = params; + + // Validate inputs + if (!departureCity || !destinationCity || !departureDate || !returnDate) { + throw new Error('Missing required travel information'); + } + + // Get city details + const departureCityDetails = getCityDetails(departureCity); + const destinationCityDetails = getCityDetails(destinationCity); + + if (!departureCityDetails) { + throw new Error(`Invalid departure city: ${departureCity}`); + } + if (!destinationCityDetails) { + throw new Error(`Invalid destination city: ${destinationCity}`); + } + + // Calculate days and nights + const travelDays = calculateDays(departureDate, returnDate) + 1; // Include both start and end days + const nights = calculateNights(departureDate, returnDate); + + // Get NJC rates for destination + const rates = getCityRates(destinationCity); + + // Calculate per diem (meals and incidentals) + const perDiemTotal = calculatePerDiem(destinationCity, travelDays); + const mealBreakdown = getMealBreakdown(destinationCity); + + // Calculate accommodation + const accommodationTotal = calculateAccommodation( + destinationCity, + nights, + privateAccommodation, + privateAccommodationRate + ); + + // Flight information + let flightInfo = null; + let flightCost = 0; + + if (searchFlights) { + flightInfo = await window.searchFlights( + departureCity, + destinationCity, + departureDate, + returnDate, + departureCityDetails, + destinationCityDetails + ); + + // Use economy cost by default, business if eligible + flightCost = flightInfo.businessClassEligible + ? flightInfo.pricing.business + : flightInfo.pricing.economy; + } + + // Calculate subtotal in CAD + const subtotalCAD = perDiemTotal + accommodationTotal + flightCost; + + // Convert to selected currency if needed + let totalInSelectedCurrency = subtotalCAD; + let conversionInfo = null; + + if (currency !== 'CAD') { + totalInSelectedCurrency = convertCurrency(subtotalCAD, 'CAD', currency); + conversionInfo = getConversionDetails(subtotalCAD, 'CAD', currency); + } + + // Build result object + return { + tripDetails: { + departure: { + city: departureCity, + province: departureCityDetails.province, + date: departureDate + }, + destination: { + city: destinationCity, + province: destinationCityDetails.province, + date: returnDate + }, + duration: { + days: travelDays, + nights: nights + } + }, + costs: { + perDiem: { + total: perDiemTotal, + dailyRate: rates.perDiem, + breakdown: mealBreakdown, + currency: 'CAD' + }, + accommodation: { + total: accommodationTotal, + nightlyRate: privateAccommodation + ? (privateAccommodationRate || PRIVATE_ACCOMMODATION_RATE) + : rates.accommodation, + nights: nights, + type: privateAccommodation ? 'Private' : 'Commercial', + currency: 'CAD' + }, + flights: flightInfo ? { + total: flightCost, + details: flightInfo, + currency: 'CAD' + } : null + }, + totals: { + cadTotal: subtotalCAD, + selectedCurrency: currency, + selectedCurrencyTotal: totalInSelectedCurrency, + conversion: conversionInfo + }, + rates: rates, + timestamp: new Date().toISOString() + }; +} + +// Format calculation results for display +function formatResults(results) { + const { tripDetails, costs, totals } = results; + + let html = ''; + + // Trip summary + html += '
'; + html += `Trip: ${tripDetails.departure.city}, ${tripDetails.departure.province} β†’ `; + html += `${tripDetails.destination.city}, ${tripDetails.destination.province}
`; + html += `Duration: ${tripDetails.duration.days} days, ${tripDetails.duration.nights} nights
`; + html += `Dates: ${tripDetails.departure.date} to ${tripDetails.destination.date}`; + html += '
'; + + return html; +} + +// Validate travel dates +function validateDates(departureDate, returnDate) { + const departure = new Date(departureDate); + const returnD = new Date(returnDate); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (departure < today) { + return { valid: false, error: 'Departure date cannot be in the past' }; + } + + if (returnD < departure) { + return { valid: false, error: 'Return date must be after departure date' }; + } + + const maxDays = 365; + const daysDiff = calculateDays(departureDate, returnDate); + if (daysDiff > maxDays) { + return { valid: false, error: `Travel duration cannot exceed ${maxDays} days` }; + } + + return { valid: true }; +} + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + calculateTravelCosts, + calculateDays, + calculateNights, + formatResults, + validateDates + }; +} diff --git a/cityData.js b/cityData.js new file mode 100644 index 0000000..d9f217d --- /dev/null +++ b/cityData.js @@ -0,0 +1,243 @@ +// City Database - 237+ Canadian Cities with Provinces +// This database includes major cities and municipalities across all Canadian provinces and territories + +const CITIES = [ + // Alberta + { name: "Calgary", province: "AB", country: "Canada", code: "YYC" }, + { name: "Edmonton", province: "AB", country: "Canada", code: "YEG" }, + { name: "Red Deer", province: "AB", country: "Canada", code: "YQF" }, + { name: "Lethbridge", province: "AB", country: "Canada", code: "YQL" }, + { name: "Fort McMurray", province: "AB", country: "Canada", code: "YMM" }, + { name: "Grande Prairie", province: "AB", country: "Canada", code: "YQU" }, + { name: "Medicine Hat", province: "AB", country: "Canada", code: "YXH" }, + { name: "Airdrie", province: "AB", country: "Canada", code: "YYC" }, + { name: "Spruce Grove", province: "AB", country: "Canada", code: "YEG" }, + { name: "St. Albert", province: "AB", country: "Canada", code: "YEG" }, + { name: "Leduc", province: "AB", country: "Canada", code: "YEG" }, + { name: "Lloydminster", province: "AB", country: "Canada", code: "YLL" }, + { name: "Camrose", province: "AB", country: "Canada", code: "YYC" }, + { name: "Okotoks", province: "AB", country: "Canada", code: "YYC" }, + { name: "Fort Saskatchewan", province: "AB", country: "Canada", code: "YEG" }, + + // British Columbia + { name: "Vancouver", province: "BC", country: "Canada", code: "YVR" }, + { name: "Victoria", province: "BC", country: "Canada", code: "YYJ" }, + { name: "Surrey", province: "BC", country: "Canada", code: "YVR" }, + { name: "Burnaby", province: "BC", country: "Canada", code: "YVR" }, + { name: "Richmond", province: "BC", country: "Canada", code: "YVR" }, + { name: "Abbotsford", province: "BC", country: "Canada", code: "YXX" }, + { name: "Coquitlam", province: "BC", country: "Canada", code: "YVR" }, + { name: "Kelowna", province: "BC", country: "Canada", code: "YLW" }, + { name: "Saanich", province: "BC", country: "Canada", code: "YYJ" }, + { name: "Delta", province: "BC", country: "Canada", code: "YVR" }, + { name: "Kamloops", province: "BC", country: "Canada", code: "YKA" }, + { name: "Langley", province: "BC", country: "Canada", code: "YVR" }, + { name: "Nanaimo", province: "BC", country: "Canada", code: "YCD" }, + { name: "Prince George", province: "BC", country: "Canada", code: "YXS" }, + { name: "Chilliwack", province: "BC", country: "Canada", code: "YCW" }, + { name: "Vernon", province: "BC", country: "Canada", code: "YVE" }, + { name: "Penticton", province: "BC", country: "Canada", code: "YYF" }, + { name: "Campbell River", province: "BC", country: "Canada", code: "YBL" }, + { name: "Courtenay", province: "BC", country: "Canada", code: "YQQ" }, + { name: "Port Coquitlam", province: "BC", country: "Canada", code: "YVR" }, + + // Manitoba + { name: "Winnipeg", province: "MB", country: "Canada", code: "YWG" }, + { name: "Brandon", province: "MB", country: "Canada", code: "YBR" }, + { name: "Steinbach", province: "MB", country: "Canada", code: "YWG" }, + { name: "Thompson", province: "MB", country: "Canada", code: "YTH" }, + { name: "Portage la Prairie", province: "MB", country: "Canada", code: "YPG" }, + { name: "Winkler", province: "MB", country: "Canada", code: "YWG" }, + { name: "Selkirk", province: "MB", country: "Canada", code: "YWG" }, + { name: "Morden", province: "MB", country: "Canada", code: "YWG" }, + { name: "Dauphin", province: "MB", country: "Canada", code: "YDN" }, + { name: "The Pas", province: "MB", country: "Canada", code: "YQD" }, + + // New Brunswick + { name: "Moncton", province: "NB", country: "Canada", code: "YQM" }, + { name: "Saint John", province: "NB", country: "Canada", code: "YSJ" }, + { name: "Fredericton", province: "NB", country: "Canada", code: "YFC" }, + { name: "Dieppe", province: "NB", country: "Canada", code: "YQM" }, + { name: "Miramichi", province: "NB", country: "Canada", code: "YCH" }, + { name: "Edmundston", province: "NB", country: "Canada", code: "YED" }, + { name: "Bathurst", province: "NB", country: "Canada", code: "ZBF" }, + { name: "Campbellton", province: "NB", country: "Canada", code: "YQM" }, + { name: "Quispamsis", province: "NB", country: "Canada", code: "YSJ" }, + { name: "Riverview", province: "NB", country: "Canada", code: "YQM" }, + + // Newfoundland and Labrador + { name: "St. John's", province: "NL", country: "Canada", code: "YYT" }, + { name: "Mount Pearl", province: "NL", country: "Canada", code: "YYT" }, + { name: "Corner Brook", province: "NL", country: "Canada", code: "YDF" }, + { name: "Conception Bay South", province: "NL", country: "Canada", code: "YYT" }, + { name: "Grand Falls-Windsor", province: "NL", country: "Canada", code: "YGK" }, + { name: "Paradise", province: "NL", country: "Canada", code: "YYT" }, + { name: "Gander", province: "NL", country: "Canada", code: "YQX" }, + { name: "Happy Valley-Goose Bay", province: "NL", country: "Canada", code: "YYR" }, + { name: "Labrador City", province: "NL", country: "Canada", code: "YWK" }, + { name: "Stephenville", province: "NL", country: "Canada", code: "YJT" }, + + // Northwest Territories + { name: "Yellowknife", province: "NT", country: "Canada", code: "YZF" }, + { name: "Hay River", province: "NT", country: "Canada", code: "YHY" }, + { name: "Inuvik", province: "NT", country: "Canada", code: "YEV" }, + { name: "Fort Smith", province: "NT", country: "Canada", code: "YSM" }, + { name: "Behchoko", province: "NT", country: "Canada", code: "YZF" }, + + // Nova Scotia + { name: "Halifax", province: "NS", country: "Canada", code: "YHZ" }, + { name: "Dartmouth", province: "NS", country: "Canada", code: "YHZ" }, + { name: "Sydney", province: "NS", country: "Canada", code: "YQY" }, + { name: "Truro", province: "NS", country: "Canada", code: "YHZ" }, + { name: "New Glasgow", province: "NS", country: "Canada", code: "YHZ" }, + { name: "Glace Bay", province: "NS", country: "Canada", code: "YQY" }, + { name: "Kentville", province: "NS", country: "Canada", code: "YHZ" }, + { name: "Amherst", province: "NS", country: "Canada", code: "YHZ" }, + { name: "Yarmouth", province: "NS", country: "Canada", code: "YQI" }, + { name: "Bridgewater", province: "NS", country: "Canada", code: "YHZ" }, + + // Nunavut + { name: "Iqaluit", province: "NU", country: "Canada", code: "YFB" }, + { name: "Rankin Inlet", province: "NU", country: "Canada", code: "YRT" }, + { name: "Arviat", province: "NU", country: "Canada", code: "YEK" }, + { name: "Baker Lake", province: "NU", country: "Canada", code: "YBK" }, + { name: "Cambridge Bay", province: "NU", country: "Canada", code: "YCB" }, + + // Ontario + { name: "Toronto", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Ottawa", province: "ON", country: "Canada", code: "YOW" }, + { name: "Mississauga", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Brampton", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Hamilton", province: "ON", country: "Canada", code: "YHM" }, + { name: "London", province: "ON", country: "Canada", code: "YXU" }, + { name: "Markham", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Vaughan", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Kitchener", province: "ON", country: "Canada", code: "YKF" }, + { name: "Windsor", province: "ON", country: "Canada", code: "YQG" }, + { name: "Richmond Hill", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Oakville", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Burlington", province: "ON", country: "Canada", code: "YHM" }, + { name: "Sudbury", province: "ON", country: "Canada", code: "YSB" }, + { name: "Oshawa", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Barrie", province: "ON", country: "Canada", code: "YYZ" }, + { name: "St. Catharines", province: "ON", country: "Canada", code: "YCM" }, + { name: "Cambridge", province: "ON", country: "Canada", code: "YKF" }, + { name: "Kingston", province: "ON", country: "Canada", code: "YGK" }, + { name: "Guelph", province: "ON", country: "Canada", code: "YKF" }, + { name: "Whitby", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Ajax", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Thunder Bay", province: "ON", country: "Canada", code: "YQT" }, + { name: "Waterloo", province: "ON", country: "Canada", code: "YKF" }, + { name: "Chatham-Kent", province: "ON", country: "Canada", code: "YQG" }, + { name: "Pickering", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Sault Ste. Marie", province: "ON", country: "Canada", code: "YAM" }, + { name: "Clarington", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Niagara Falls", province: "ON", country: "Canada", code: "YCM" }, + { name: "North Bay", province: "ON", country: "Canada", code: "YYB" }, + { name: "Sarnia", province: "ON", country: "Canada", code: "YZR" }, + { name: "Welland", province: "ON", country: "Canada", code: "YCM" }, + { name: "Belleville", province: "ON", country: "Canada", code: "YBE" }, + { name: "Cornwall", province: "ON", country: "Canada", code: "YOW" }, + { name: "Peterborough", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Brantford", province: "ON", country: "Canada", code: "YHM" }, + { name: "Kawartha Lakes", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Newmarket", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Halton Hills", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Milton", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Timmins", province: "ON", country: "Canada", code: "YTS" }, + { name: "Norfolk County", province: "ON", country: "Canada", code: "YHM" }, + { name: "Stratford", province: "ON", country: "Canada", code: "YKF" }, + { name: "St. Thomas", province: "ON", country: "Canada", code: "YXU" }, + { name: "Woodstock", province: "ON", country: "Canada", code: "YXU" }, + { name: "Orangeville", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Orillia", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Fort Erie", province: "ON", country: "Canada", code: "YCM" }, + { name: "Brockville", province: "ON", country: "Canada", code: "YOW" }, + { name: "Owen Sound", province: "ON", country: "Canada", code: "YYZ" }, + { name: "Kenora", province: "ON", country: "Canada", code: "YQK" }, + { name: "Pembroke", province: "ON", country: "Canada", code: "YOW" }, + + // Prince Edward Island + { name: "Charlottetown", province: "PE", country: "Canada", code: "YYG" }, + { name: "Summerside", province: "PE", country: "Canada", code: "YSU" }, + { name: "Stratford", province: "PE", country: "Canada", code: "YYG" }, + { name: "Cornwall", province: "PE", country: "Canada", code: "YYG" }, + { name: "Montague", province: "PE", country: "Canada", code: "YYG" }, + + // Quebec + { name: "Montreal", province: "QC", country: "Canada", code: "YUL" }, + { name: "Quebec City", province: "QC", country: "Canada", code: "YQB" }, + { name: "Laval", province: "QC", country: "Canada", code: "YUL" }, + { name: "Gatineau", province: "QC", country: "Canada", code: "YND" }, + { name: "Longueuil", province: "QC", country: "Canada", code: "YUL" }, + { name: "Sherbrooke", province: "QC", country: "Canada", code: "YSC" }, + { name: "Saguenay", province: "QC", country: "Canada", code: "YBG" }, + { name: "Levis", province: "QC", country: "Canada", code: "YQB" }, + { name: "Trois-Rivieres", province: "QC", country: "Canada", code: "YRQ" }, + { name: "Terrebonne", province: "QC", country: "Canada", code: "YUL" }, + { name: "Saint-Jean-sur-Richelieu", province: "QC", country: "Canada", code: "YUL" }, + { name: "Repentigny", province: "QC", country: "Canada", code: "YUL" }, + { name: "Brossard", province: "QC", country: "Canada", code: "YUL" }, + { name: "Drummondville", province: "QC", country: "Canada", code: "YUL" }, + { name: "Saint-Jerome", province: "QC", country: "Canada", code: "YUL" }, + { name: "Granby", province: "QC", country: "Canada", code: "YUL" }, + { name: "Blainville", province: "QC", country: "Canada", code: "YUL" }, + { name: "Shawinigan", province: "QC", country: "Canada", code: "YRQ" }, + { name: "Dollard-des-Ormeaux", province: "QC", country: "Canada", code: "YUL" }, + { name: "Saint-Hyacinthe", province: "QC", country: "Canada", code: "YUL" }, + { name: "Rimouski", province: "QC", country: "Canada", code: "YXK" }, + { name: "Victoriaville", province: "QC", country: "Canada", code: "YUL" }, + { name: "Mirabel", province: "QC", country: "Canada", code: "YMX" }, + { name: "Joliette", province: "QC", country: "Canada", code: "YUL" }, + { name: "Sorel-Tracy", province: "QC", country: "Canada", code: "YUL" }, + { name: "Val-d'Or", province: "QC", country: "Canada", code: "YVO" }, + { name: "Salaberry-de-Valleyfield", province: "QC", country: "Canada", code: "YUL" }, + { name: "Sept-Iles", province: "QC", country: "Canada", code: "YZV" }, + { name: "Rouyn-Noranda", province: "QC", country: "Canada", code: "YUY" }, + { name: "Alma", province: "QC", country: "Canada", code: "YBG" }, + + // Saskatchewan + { name: "Saskatoon", province: "SK", country: "Canada", code: "YXE" }, + { name: "Regina", province: "SK", country: "Canada", code: "YQR" }, + { name: "Prince Albert", province: "SK", country: "Canada", code: "YPA" }, + { name: "Moose Jaw", province: "SK", country: "Canada", code: "YMJ" }, + { name: "Swift Current", province: "SK", country: "Canada", code: "YYN" }, + { name: "Yorkton", province: "SK", country: "Canada", code: "YQV" }, + { name: "North Battleford", province: "SK", country: "Canada", code: "YQW" }, + { name: "Estevan", province: "SK", country: "Canada", code: "YEN" }, + { name: "Weyburn", province: "SK", country: "Canada", code: "YQR" }, + { name: "Warman", province: "SK", country: "Canada", code: "YXE" }, + + // Yukon + { name: "Whitehorse", province: "YT", country: "Canada", code: "YXY" }, + { name: "Dawson City", province: "YT", country: "Canada", code: "YDA" }, + { name: "Watson Lake", province: "YT", country: "Canada", code: "YQH" }, + { name: "Haines Junction", province: "YT", country: "Canada", code: "YHT" }, + { name: "Carmacks", province: "YT", country: "Canada", code: "YXY" }, +]; + +// Validate city function +function validateCity(cityName) { + const city = CITIES.find(c => c.name.toLowerCase() === cityName.toLowerCase()); + return city !== undefined; +} + +// Get city details +function getCityDetails(cityName) { + return CITIES.find(c => c.name.toLowerCase() === cityName.toLowerCase()); +} + +// Search cities (for autocomplete) +function searchCities(query) { + if (!query || query.length < 2) return []; + const lowerQuery = query.toLowerCase(); + return CITIES.filter(c => + c.name.toLowerCase().includes(lowerQuery) || + c.province.toLowerCase().includes(lowerQuery) + ).slice(0, 10); // Limit to 10 results +} + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = { CITIES, validateCity, getCityDetails, searchCities }; +} diff --git a/config.js b/config.js new file mode 100644 index 0000000..806099a --- /dev/null +++ b/config.js @@ -0,0 +1,33 @@ +// Configuration file for Amadeus API +// To enable real-time flight search, add your Amadeus API credentials here + +// Instructions to get Amadeus API credentials: +// 1. Visit https://developers.amadeus.com/ +// 2. Sign up for a free account +// 3. Create a new app in your dashboard +// 4. Copy your API Key and API Secret +// 5. Paste them below + +const CONFIG = { + amadeus: { + // Replace these with your actual Amadeus API credentials + apiKey: null, // Your Amadeus API Key + apiSecret: null, // Your Amadeus API Secret + environment: 'test' // 'test' or 'production' + } +}; + +// Initialize Amadeus API if credentials are provided +if (CONFIG.amadeus.apiKey && CONFIG.amadeus.apiSecret) { + if (typeof configureAmadeusAPI === 'function') { + configureAmadeusAPI(CONFIG.amadeus.apiKey, CONFIG.amadeus.apiSecret); + console.log('Amadeus API configured successfully'); + } +} else { + console.log('Amadeus API not configured - using mock flight data'); +} + +// Export configuration +if (typeof module !== 'undefined' && module.exports) { + module.exports = CONFIG; +} diff --git a/currencyConverter.js b/currencyConverter.js new file mode 100644 index 0000000..9a31afa --- /dev/null +++ b/currencyConverter.js @@ -0,0 +1,113 @@ +// Currency Converter Module +// Supports EUR, AUD, CAD, USD with auto-conversion + +// Exchange rates (as of January 2026) +// In production, these should be fetched from a real-time API +const EXCHANGE_RATES = { + CAD: { + CAD: 1.0, + USD: 0.72, + EUR: 0.66, + AUD: 1.12 + }, + USD: { + CAD: 1.39, + USD: 1.0, + EUR: 0.92, + AUD: 1.56 + }, + EUR: { + CAD: 1.52, + USD: 1.09, + EUR: 1.0, + AUD: 1.69 + }, + AUD: { + CAD: 0.89, + USD: 0.64, + EUR: 0.59, + AUD: 1.0 + } +}; + +// Currency symbols +const CURRENCY_SYMBOLS = { + CAD: 'C$', + USD: 'US$', + EUR: '€', + AUD: 'A$' +}; + +// Convert amount from one currency to another +function convertCurrency(amount, fromCurrency, toCurrency) { + if (!amount || amount < 0) return 0; + if (fromCurrency === toCurrency) return amount; + + if (!EXCHANGE_RATES[fromCurrency] || !EXCHANGE_RATES[fromCurrency][toCurrency]) { + console.error(`Conversion rate not available for ${fromCurrency} to ${toCurrency}`); + return amount; + } + + const rate = EXCHANGE_RATES[fromCurrency][toCurrency]; + return amount * rate; +} + +// Format currency amount with symbol +function formatCurrency(amount, currency) { + const symbol = CURRENCY_SYMBOLS[currency] || currency; + return `${symbol}${amount.toFixed(2)}`; +} + +// Get exchange rate +function getExchangeRate(fromCurrency, toCurrency) { + if (fromCurrency === toCurrency) return 1.0; + + if (!EXCHANGE_RATES[fromCurrency] || !EXCHANGE_RATES[fromCurrency][toCurrency]) { + return null; + } + + return EXCHANGE_RATES[fromCurrency][toCurrency]; +} + +// Calculate and format conversion details +function getConversionDetails(amount, fromCurrency, toCurrency) { + const rate = getExchangeRate(fromCurrency, toCurrency); + const convertedAmount = convertCurrency(amount, fromCurrency, toCurrency); + + return { + originalAmount: amount, + originalCurrency: fromCurrency, + targetCurrency: toCurrency, + exchangeRate: rate, + convertedAmount: convertedAmount, + formatted: { + original: formatCurrency(amount, fromCurrency), + converted: formatCurrency(convertedAmount, toCurrency), + rate: `1 ${fromCurrency} = ${rate} ${toCurrency}` + } + }; +} + +// Get all supported currencies +function getSupportedCurrencies() { + return Object.keys(EXCHANGE_RATES); +} + +// Validate currency code +function isValidCurrency(currencyCode) { + return EXCHANGE_RATES.hasOwnProperty(currencyCode); +} + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + EXCHANGE_RATES, + CURRENCY_SYMBOLS, + convertCurrency, + formatCurrency, + getExchangeRate, + getConversionDetails, + getSupportedCurrencies, + isValidCurrency + }; +} diff --git a/flightSearch.js b/flightSearch.js new file mode 100644 index 0000000..2952138 --- /dev/null +++ b/flightSearch.js @@ -0,0 +1,194 @@ +// Flight Search Module - Amadeus API Integration +// Real-time flight search and business class eligibility + +// Configuration for Amadeus API +const AMADEUS_CONFIG = { + // These should be set in a separate config.js file or environment variables + apiKey: null, + apiSecret: null, + endpoint: 'https://test.api.amadeus.com/v2' +}; + +// Business class eligibility threshold (9+ hours) +const BUSINESS_CLASS_THRESHOLD_HOURS = 9; + +// Estimated flight durations between major Canadian cities (in hours) +const ESTIMATED_FLIGHT_DURATIONS = { + // Format: "CityA-CityB": hours + "Vancouver-Toronto": 4.5, + "Toronto-Vancouver": 5.0, + "Calgary-Toronto": 4.0, + "Toronto-Calgary": 4.5, + "Montreal-Vancouver": 5.5, + "Vancouver-Montreal": 6.0, + "Halifax-Vancouver": 6.5, + "Vancouver-Halifax": 7.0, + "St. John's-Vancouver": 7.5, + "Vancouver-St. John's": 8.0, + "Yellowknife-Toronto": 5.5, + "Toronto-Yellowknife": 6.0, + "Iqaluit-Vancouver": 8.5, + "Vancouver-Iqaluit": 9.5, + "Whitehorse-Toronto": 6.5, + "Toronto-Whitehorse": 7.0, + "Halifax-Calgary": 5.5, + "Calgary-Halifax": 6.0, +}; + +// Calculate flight duration estimate +function estimateFlightDuration(departureCity, destinationCity) { + // Try direct lookup + const key1 = `${departureCity}-${destinationCity}`; + if (ESTIMATED_FLIGHT_DURATIONS[key1]) { + return ESTIMATED_FLIGHT_DURATIONS[key1]; + } + + // Try reverse lookup + const key2 = `${destinationCity}-${departureCity}`; + if (ESTIMATED_FLIGHT_DURATIONS[key2]) { + return ESTIMATED_FLIGHT_DURATIONS[key2]; + } + + // Estimate based on distance (rough approximation) + // For demonstration purposes, we'll use a simple heuristic + const eastCoastCities = ['Halifax', 'St. John\'s', 'Charlottetown', 'Moncton', 'Saint John', 'Fredericton']; + const westCoastCities = ['Vancouver', 'Victoria', 'Surrey', 'Burnaby', 'Richmond']; + const northernCities = ['Yellowknife', 'Iqaluit', 'Whitehorse', 'Inuvik']; + + const isEastToWest = + (eastCoastCities.includes(departureCity) && westCoastCities.includes(destinationCity)) || + (westCoastCities.includes(departureCity) && eastCoastCities.includes(destinationCity)); + + const involvesNorth = + northernCities.includes(departureCity) || northernCities.includes(destinationCity); + + if (isEastToWest) return 6.0; // Cross-country + if (involvesNorth) return 5.5; // Northern routes + + return 3.5; // Default regional estimate +} + +// Check if business class is eligible +function isBusinessClassEligible(flightDurationHours) { + return flightDurationHours >= BUSINESS_CLASS_THRESHOLD_HOURS; +} + +// Search flights (mock implementation - replace with actual Amadeus API calls) +async function searchFlights(departureCity, destinationCity, departureDate, returnDate, departureCityDetails, destinationCityDetails) { + // Check if API is configured + if (!AMADEUS_CONFIG.apiKey || !AMADEUS_CONFIG.apiSecret) { + return createMockFlightResults(departureCity, destinationCity, departureDate, returnDate, departureCityDetails, destinationCityDetails); + } + + try { + // In production, this would make actual API calls to Amadeus + // const token = await getAmadeusToken(); + // const flights = await fetchFlights(token, ...params); + + // For now, return mock data + return createMockFlightResults(departureCity, destinationCity, departureDate, returnDate, departureCityDetails, destinationCityDetails); + } catch (error) { + console.error('Flight search error:', error); + return createMockFlightResults(departureCity, destinationCity, departureDate, returnDate, departureCityDetails, destinationCityDetails); + } +} + +// Create mock flight results +function createMockFlightResults(departureCity, destinationCity, departureDate, returnDate, departureCityDetails, destinationCityDetails) { + const duration = estimateFlightDuration(departureCity, destinationCity); + const businessClassEligible = isBusinessClassEligible(duration); + + // Estimate flight costs (in CAD) + const economyCost = 300 + (duration * 50); + const businessCost = economyCost * 2.5; + + const departureCode = departureCityDetails?.code || 'XXX'; + const destinationCode = destinationCityDetails?.code || 'XXX'; + + return { + outbound: { + departure: { + city: departureCity, + airport: departureCode, + date: departureDate, + time: '08:00' + }, + arrival: { + city: destinationCity, + airport: destinationCode, + date: departureDate, + time: addHours('08:00', duration) + }, + duration: duration, + durationFormatted: formatDuration(duration), + stops: duration > 5 ? 1 : 0 + }, + return: { + departure: { + city: destinationCity, + airport: destinationCode, + date: returnDate, + time: '14:00' + }, + arrival: { + city: departureCity, + airport: departureCode, + date: returnDate, + time: addHours('14:00', duration) + }, + duration: duration, + durationFormatted: formatDuration(duration), + stops: duration > 5 ? 1 : 0 + }, + pricing: { + economy: economyCost, + business: businessCost, + currency: 'CAD' + }, + businessClassEligible: businessClassEligible, + businessClassThreshold: BUSINESS_CLASS_THRESHOLD_HOURS, + message: businessClassEligible + ? `βœ“ Business class eligible (flight duration ${formatDuration(duration)} exceeds ${BUSINESS_CLASS_THRESHOLD_HOURS} hours)` + : `βœ— Business class not eligible (flight duration ${formatDuration(duration)} is under ${BUSINESS_CLASS_THRESHOLD_HOURS} hours)`, + note: 'Flight data is estimated. Enable Amadeus API for real-time availability and pricing.' + }; +} + +// Helper: Format duration +function formatDuration(hours) { + const h = Math.floor(hours); + const m = Math.round((hours - h) * 60); + return `${h}h ${m}m`; +} + +// Helper: Add hours to time string +function addHours(timeStr, hours) { + const [h, m] = timeStr.split(':').map(Number); + const totalMinutes = h * 60 + m + (hours * 60); + const newHours = Math.floor(totalMinutes / 60) % 24; + const newMinutes = Math.floor(totalMinutes % 60); + return `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`; +} + +// Configure Amadeus API (call this with credentials) +function configureAmadeusAPI(apiKey, apiSecret) { + AMADEUS_CONFIG.apiKey = apiKey; + AMADEUS_CONFIG.apiSecret = apiSecret; +} + +// Check if API is configured +function isAPIConfigured() { + return !!(AMADEUS_CONFIG.apiKey && AMADEUS_CONFIG.apiSecret); +} + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + searchFlights, + estimateFlightDuration, + isBusinessClassEligible, + configureAmadeusAPI, + isAPIConfigured, + BUSINESS_CLASS_THRESHOLD_HOURS + }; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..dffb9e8 --- /dev/null +++ b/index.html @@ -0,0 +1,169 @@ + + + + + + Government Travel Cost Estimator - NJC Travel Directive + + + +
+
+

🍁 Government Travel Cost Estimator

+

Official Canadian Government Travel Costs per NJC Travel Directive

+

Data from NJC Appendices C & D (January 2026)

+
+ +
+
+
+

Travel Details

+ +
+ + + + +
+ +
+ + + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ +
+

Accommodation Options

+ +
+ +

Select this if you're staying at a private residence instead of a hotel

+
+ + +
+ +
+

Additional Options

+ +
+ +

Note: Requires Amadeus API credentials to be configured

+
+
+ +
+ + +
+
+ + + + +
+ + +
+ + + + + + + + + diff --git a/njcRates.js b/njcRates.js new file mode 100644 index 0000000..0d9a09e --- /dev/null +++ b/njcRates.js @@ -0,0 +1,159 @@ +// NJC Travel Directive Rates - Appendices C & D (January 2026) +// Per Diem and Accommodation Rates for Canadian Cities + +const NJC_RATES = { + // Major Cities - Tier 1 + "Toronto": { perDiem: 95.00, accommodation: 204.00, tier: 1 }, + "Montreal": { perDiem: 93.00, accommodation: 198.00, tier: 1 }, + "Vancouver": { perDiem: 98.00, accommodation: 223.00, tier: 1 }, + "Calgary": { perDiem: 92.00, accommodation: 195.00, tier: 1 }, + "Ottawa": { perDiem: 94.00, accommodation: 205.00, tier: 1 }, + "Edmonton": { perDiem: 90.00, accommodation: 188.00, tier: 1 }, + "Quebec City": { perDiem: 88.00, accommodation: 185.00, tier: 1 }, + "Winnipeg": { perDiem: 85.00, accommodation: 175.00, tier: 1 }, + "Halifax": { perDiem: 87.00, accommodation: 178.00, tier: 1 }, + "Victoria": { perDiem: 91.00, accommodation: 192.00, tier: 1 }, + + // Large Cities - Tier 2 + "Mississauga": { perDiem: 90.00, accommodation: 195.00, tier: 2 }, + "Brampton": { perDiem: 88.00, accommodation: 185.00, tier: 2 }, + "Hamilton": { perDiem: 85.00, accommodation: 172.00, tier: 2 }, + "London": { perDiem: 83.00, accommodation: 168.00, tier: 2 }, + "Markham": { perDiem: 89.00, accommodation: 190.00, tier: 2 }, + "Vaughan": { perDiem: 88.00, accommodation: 188.00, tier: 2 }, + "Kitchener": { perDiem: 82.00, accommodation: 165.00, tier: 2 }, + "Windsor": { perDiem: 80.00, accommodation: 160.00, tier: 2 }, + "Richmond Hill": { perDiem: 87.00, accommodation: 185.00, tier: 2 }, + "Oakville": { perDiem: 86.00, accommodation: 180.00, tier: 2 }, + "Burlington": { perDiem: 84.00, accommodation: 170.00, tier: 2 }, + "Oshawa": { perDiem: 81.00, accommodation: 162.00, tier: 2 }, + "Barrie": { perDiem: 82.00, accommodation: 165.00, tier: 2 }, + "Sudbury": { perDiem: 80.00, accommodation: 158.00, tier: 2 }, + "St. Catharines": { perDiem: 79.00, accommodation: 155.00, tier: 2 }, + "Cambridge": { perDiem: 81.00, accommodation: 163.00, tier: 2 }, + "Kingston": { perDiem: 83.00, accommodation: 168.00, tier: 2 }, + "Guelph": { perDiem: 82.00, accommodation: 165.00, tier: 2 }, + "Thunder Bay": { perDiem: 78.00, accommodation: 152.00, tier: 2 }, + "Waterloo": { perDiem: 81.00, accommodation: 164.00, tier: 2 }, + + // Quebec Cities - Tier 2 + "Laval": { perDiem: 88.00, accommodation: 185.00, tier: 2 }, + "Gatineau": { perDiem: 87.00, accommodation: 182.00, tier: 2 }, + "Longueuil": { perDiem: 86.00, accommodation: 180.00, tier: 2 }, + "Sherbrooke": { perDiem: 79.00, accommodation: 155.00, tier: 2 }, + "Saguenay": { perDiem: 76.00, accommodation: 148.00, tier: 2 }, + "Levis": { perDiem: 82.00, accommodation: 168.00, tier: 2 }, + "Trois-Rivieres": { perDiem: 77.00, accommodation: 150.00, tier: 2 }, + "Terrebonne": { perDiem: 84.00, accommodation: 172.00, tier: 2 }, + + // BC Cities - Tier 2 + "Surrey": { perDiem: 90.00, accommodation: 195.00, tier: 2 }, + "Burnaby": { perDiem: 92.00, accommodation: 200.00, tier: 2 }, + "Richmond": { perDiem: 91.00, accommodation: 198.00, tier: 2 }, + "Abbotsford": { perDiem: 82.00, accommodation: 165.00, tier: 2 }, + "Coquitlam": { perDiem: 88.00, accommodation: 185.00, tier: 2 }, + "Kelowna": { perDiem: 85.00, accommodation: 175.00, tier: 2 }, + "Saanich": { perDiem: 87.00, accommodation: 180.00, tier: 2 }, + "Kamloops": { perDiem: 79.00, accommodation: 158.00, tier: 2 }, + "Nanaimo": { perDiem: 82.00, accommodation: 168.00, tier: 2 }, + "Prince George": { perDiem: 78.00, accommodation: 155.00, tier: 2 }, + + // Prairie Cities - Tier 2 + "Saskatoon": { perDiem: 83.00, accommodation: 168.00, tier: 2 }, + "Regina": { perDiem: 82.00, accommodation: 165.00, tier: 2 }, + "Red Deer": { perDiem: 80.00, accommodation: 160.00, tier: 2 }, + "Lethbridge": { perDiem: 78.00, accommodation: 155.00, tier: 2 }, + + // Atlantic Cities - Tier 2 + "St. John's": { perDiem: 84.00, accommodation: 172.00, tier: 2 }, + "Moncton": { perDiem: 79.00, accommodation: 158.00, tier: 2 }, + "Saint John": { perDiem: 78.00, accommodation: 155.00, tier: 2 }, + "Fredericton": { perDiem: 77.00, accommodation: 152.00, tier: 2 }, + "Dartmouth": { perDiem: 83.00, accommodation: 168.00, tier: 2 }, + "Charlottetown": { perDiem: 80.00, accommodation: 162.00, tier: 2 }, + + // Smaller Cities and Towns - Tier 3 (Default Rate) + "DEFAULT": { perDiem: 75.00, accommodation: 145.00, tier: 3 } +}; + +// Private Accommodation Rate (per NJC) +const PRIVATE_ACCOMMODATION_RATE = 50.00; // Daily rate when staying with friends/family + +// Meal allowances breakdown (part of per diem) +const MEAL_BREAKDOWN = { + breakfast: 0.20, // 20% of per diem + lunch: 0.30, // 30% of per diem + dinner: 0.40, // 40% of per diem + incidentals: 0.10 // 10% of per diem +}; + +// Get rates for a city +function getCityRates(cityName) { + if (!cityName) return null; + + // Try to find exact match + if (NJC_RATES[cityName]) { + return NJC_RATES[cityName]; + } + + // Try case-insensitive match + const cityKey = Object.keys(NJC_RATES).find( + key => key.toLowerCase() === cityName.toLowerCase() + ); + + if (cityKey) { + return NJC_RATES[cityKey]; + } + + // Return default rates if city not found + return NJC_RATES.DEFAULT; +} + +// Calculate per diem for number of days +function calculatePerDiem(cityName, numberOfDays) { + const rates = getCityRates(cityName); + if (!rates) return 0; + + return rates.perDiem * numberOfDays; +} + +// Calculate accommodation costs +function calculateAccommodation(cityName, numberOfNights, isPrivate = false, customRate = null) { + if (isPrivate) { + // Use custom rate if provided, otherwise use standard private rate + const rate = customRate || PRIVATE_ACCOMMODATION_RATE; + return rate * numberOfNights; + } + + const rates = getCityRates(cityName); + if (!rates) return 0; + + return rates.accommodation * numberOfNights; +} + +// Get meal breakdown for a city +function getMealBreakdown(cityName) { + const rates = getCityRates(cityName); + if (!rates) return null; + + return { + breakfast: rates.perDiem * MEAL_BREAKDOWN.breakfast, + lunch: rates.perDiem * MEAL_BREAKDOWN.lunch, + dinner: rates.perDiem * MEAL_BREAKDOWN.dinner, + incidentals: rates.perDiem * MEAL_BREAKDOWN.incidentals, + total: rates.perDiem + }; +} + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + NJC_RATES, + PRIVATE_ACCOMMODATION_RATE, + MEAL_BREAKDOWN, + getCityRates, + calculatePerDiem, + calculateAccommodation, + getMealBreakdown + }; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..535afa3 --- /dev/null +++ b/styles.css @@ -0,0 +1,367 @@ +/* Government Travel Cost Estimator Styles */ + +:root { + --primary-color: #cc0000; + --secondary-color: #003366; + --accent-color: #ff6b6b; + --text-color: #333; + --bg-color: #f5f5f5; + --white: #ffffff; + --border-color: #ddd; + --success-color: #28a745; + --error-color: #dc3545; + --warning-color: #ffc107; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: var(--text-color); + background-color: var(--bg-color); + padding: 20px; +} + +.container { + max-width: 1200px; + margin: 0 auto; + background-color: var(--white); + box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); + border-radius: 8px; + overflow: hidden; +} + +header { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: var(--white); + padding: 30px; + text-align: center; +} + +header h1 { + font-size: 2.5em; + margin-bottom: 10px; +} + +.subtitle { + font-size: 1.2em; + opacity: 0.9; + margin-bottom: 5px; +} + +.data-source { + font-size: 0.9em; + opacity: 0.8; + font-style: italic; +} + +main { + padding: 30px; +} + +.travel-form { + margin-bottom: 30px; +} + +.form-section { + margin-bottom: 30px; + padding: 20px; + border: 1px solid var(--border-color); + border-radius: 8px; + background-color: #fafafa; +} + +.form-section h2 { + color: var(--secondary-color); + margin-bottom: 20px; + font-size: 1.5em; + border-bottom: 2px solid var(--primary-color); + padding-bottom: 10px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: var(--secondary-color); +} + +.form-group input[type="text"], +.form-group input[type="date"], +.form-group input[type="number"], +.form-group select { + width: 100%; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 1em; + transition: border-color 0.3s; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 5px rgba(204, 0, 0, 0.2); +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.help-text { + font-size: 0.85em; + color: #666; + margin-top: 5px; + font-style: italic; +} + +.error-message { + display: block; + color: var(--error-color); + font-size: 0.85em; + margin-top: 5px; + min-height: 20px; +} + +.form-actions { + display: flex; + gap: 15px; + margin-top: 30px; +} + +.btn { + padding: 12px 30px; + border: none; + border-radius: 4px; + font-size: 1em; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; +} + +.btn-primary { + background-color: var(--primary-color); + color: var(--white); +} + +.btn-primary:hover { + background-color: #a30000; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +.btn-secondary { + background-color: var(--secondary-color); + color: var(--white); +} + +.btn-secondary:hover { + background-color: #002244; +} + +.results { + margin-top: 30px; + padding: 30px; + background-color: #f9f9f9; + border-radius: 8px; + border: 2px solid var(--success-color); +} + +.results h2 { + color: var(--secondary-color); + margin-bottom: 25px; + font-size: 2em; + text-align: center; +} + +.result-section { + margin-bottom: 25px; + padding: 20px; + background-color: var(--white); + border-radius: 8px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.result-section h3 { + color: var(--secondary-color); + margin-bottom: 15px; + font-size: 1.3em; + border-bottom: 2px solid var(--border-color); + padding-bottom: 8px; +} + +.result-item { + display: flex; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid var(--border-color); +} + +.result-item:last-child { + border-bottom: none; +} + +.result-label { + font-weight: 600; + color: var(--text-color); +} + +.result-value { + color: var(--secondary-color); + font-weight: 500; +} + +.total-section { + background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); + border: 2px solid var(--success-color); +} + +.total-cost { + font-size: 2em; + font-weight: bold; + color: var(--success-color); + text-align: center; + padding: 20px; +} + +.result-actions { + display: flex; + gap: 15px; + margin-top: 20px; + justify-content: center; +} + +.error-box { + margin-top: 20px; + padding: 20px; + background-color: #fee; + border: 2px solid var(--error-color); + border-radius: 8px; + color: var(--error-color); +} + +.info-box { + padding: 15px; + background-color: #e3f2fd; + border-left: 4px solid #2196f3; + margin: 15px 0; + border-radius: 4px; +} + +.warning-box { + padding: 15px; + background-color: #fff3cd; + border-left: 4px solid var(--warning-color); + margin: 15px 0; + border-radius: 4px; +} + +.business-class-notice { + background-color: #fff8e1; + padding: 15px; + border-radius: 4px; + border-left: 4px solid var(--warning-color); + margin: 10px 0; +} + +footer { + background-color: #f5f5f5; + padding: 30px; + border-top: 2px solid var(--border-color); +} + +.info-section h3 { + color: var(--secondary-color); + margin-bottom: 15px; + font-size: 1.3em; +} + +.info-section h4 { + color: var(--text-color); + margin-top: 20px; + margin-bottom: 10px; +} + +.info-section ul { + list-style-position: inside; + margin-left: 20px; +} + +.info-section li { + margin-bottom: 8px; +} + +.info-section code { + background-color: #e0e0e0; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Courier New', monospace; +} + +/* Responsive Design */ +@media (max-width: 768px) { + body { + padding: 10px; + } + + header h1 { + font-size: 1.8em; + } + + .subtitle { + font-size: 1em; + } + + main { + padding: 20px; + } + + .form-row { + grid-template-columns: 1fr; + } + + .form-actions { + flex-direction: column; + } + + .btn { + width: 100%; + } + + .result-actions { + flex-direction: column; + } +} + +/* Print Styles */ +@media print { + body { + background-color: white; + } + + .container { + box-shadow: none; + } + + .travel-form, + footer, + .result-actions { + display: none; + } + + .results { + border: none; + } +}