// ========================================
// SCRIPT INITIALIZATION - Verify Loading
// ========================================
console.log("đĨ script.js is LOADING...");
// Global variables for database
let perDiemRatesDB = null;
let accommodationRatesDB = null;
let transportationRatesDB = null;
const MAX_CITY_SUGGESTIONS = 20;
let citySuggestionPool = [];
let baseCityListOptions = [];
let ALL_CITIES = []; // Loaded from database API
// Business class multiplier
const BUSINESS_CLASS_MULTIPLIER = 2.5;
const BUSINESS_CLASS_THRESHOLD_HOURS = 9;
// Load databases
async function loadDatabases() {
try {
const [perDiemResponse, accommodationResponse, transportationResponse] =
await Promise.all([
fetch("data/perDiemRates.json"),
fetch("data/accommodationRates.json"),
fetch("data/transportationRates.json"),
]);
if (
!perDiemResponse.ok ||
!accommodationResponse.ok ||
!transportationResponse.ok
) {
throw new Error("Failed to load rate databases");
}
perDiemRatesDB = await perDiemResponse.json();
accommodationRatesDB = await accommodationResponse.json();
transportationRatesDB = await transportationResponse.json();
// Update metadata display if databases loaded successfully
updateMetadataDisplay();
return true;
} catch (error) {
console.error("Error loading databases:", error);
alert("Error loading rate databases. Please refresh the page.");
return false;
}
}
function updateMetadataDisplay() {
if (perDiemRatesDB && perDiemRatesDB.metadata) {
const footer = document.querySelector("footer p");
if (footer) {
footer.textContent = `Based on NJC Travel Directive effective ${perDiemRatesDB.metadata.effectiveDate} (Rates updated: ${perDiemRatesDB.metadata.lastUpdated})`;
}
}
// Validate rates and show warnings if needed
validateRatesAndShowWarnings();
}
// Validate database dates and show warnings
function validateRatesAndShowWarnings() {
const warnings = [];
const today = new Date();
// Check per diem rates
if (perDiemRatesDB && perDiemRatesDB.metadata) {
const effectiveDate = new Date(perDiemRatesDB.metadata.effectiveDate);
const lastUpdated = new Date(perDiemRatesDB.metadata.lastUpdated);
const monthsSinceUpdate =
(today - lastUpdated) / (1000 * 60 * 60 * 24 * 30);
if (monthsSinceUpdate > 12) {
warnings.push({
type: "outdated",
database: "Per Diem Rates",
message: `Per diem rates were last updated ${lastUpdated.toLocaleDateString()} (${Math.floor(
monthsSinceUpdate
)} months ago). Please verify current rates.`,
lastUpdated: perDiemRatesDB.metadata.lastUpdated,
});
} else if (monthsSinceUpdate > 10) {
warnings.push({
type: "warning",
database: "Per Diem Rates",
message: `Per diem rates approaching update cycle. Last updated ${lastUpdated.toLocaleDateString()}.`,
lastUpdated: perDiemRatesDB.metadata.lastUpdated,
});
}
}
// Check accommodation rates
if (accommodationRatesDB && accommodationRatesDB.metadata) {
const lastUpdated = new Date(accommodationRatesDB.metadata.lastUpdated);
const monthsSinceUpdate =
(today - lastUpdated) / (1000 * 60 * 60 * 24 * 30);
if (monthsSinceUpdate > 6) {
warnings.push({
type: "info",
database: "Accommodation Rates",
message: `Accommodation rates were last updated ${lastUpdated.toLocaleDateString()}. Verify current rates for specific cities.`,
lastUpdated: accommodationRatesDB.metadata.lastUpdated,
});
}
}
// Check transportation rates
if (transportationRatesDB && transportationRatesDB.metadata) {
const effectiveDate = new Date(
transportationRatesDB.metadata.effectiveDate
);
const lastUpdated = new Date(transportationRatesDB.metadata.lastUpdated);
const monthsSinceUpdate =
(today - lastUpdated) / (1000 * 60 * 60 * 24 * 30);
if (monthsSinceUpdate > 12) {
warnings.push({
type: "outdated",
database: "Transportation Rates",
message: `Kilometric rates were last updated ${lastUpdated.toLocaleDateString()} (${Math.floor(
monthsSinceUpdate
)} months ago). Please verify current rates.`,
lastUpdated: transportationRatesDB.metadata.lastUpdated,
});
}
}
// Display warnings if any
if (warnings.length > 0) {
displayRateWarnings(warnings);
}
return warnings;
}
// Display rate validation warnings
function displayRateWarnings(warnings) {
// Check if warning banner already exists
let warningBanner = document.getElementById("rateWarningBanner");
if (!warningBanner) {
warningBanner = document.createElement("div");
warningBanner.id = "rateWarningBanner";
warningBanner.className = "rate-warning-banner";
// Insert after header
const header = document.querySelector("header");
header.parentNode.insertBefore(warningBanner, header.nextSibling);
}
// Build warning content
let content = '
';
content += "
â ī¸ Rate Validation Notice
";
warnings.forEach((warning) => {
const alertClass =
warning.type === "outdated"
? "alert-danger"
: warning.type === "warning"
? "alert-warning"
: "alert-info";
content += `
`;
content += `${warning.database}: ${warning.message}`;
content += "
";
});
content +=
'';
content +=
'
';
content += "
";
warningBanner.innerHTML = content;
warningBanner.style.display = "block";
}
// Dismiss warning banner
function dismissWarningBanner() {
const banner = document.getElementById("rateWarningBanner");
if (banner) {
banner.style.display = "none";
// Store dismissal in session storage
sessionStorage.setItem("warningDismissed", "true");
}
}
// Check if warning was dismissed this session
function shouldShowWarning() {
return !sessionStorage.getItem("warningDismissed");
}
// Form elements
const form = document.getElementById("travelForm");
const resultsSection = document.getElementById("results");
// Result elements
const totalCostEl = document.getElementById("totalCost");
const transportLabelEl = document.getElementById("transportLabel");
const transportCostEl = document.getElementById("transportCost");
const transportNoteEl = document.getElementById("transportNote");
const accommodationCostEl = document.getElementById("accommodationCost");
const accommodationNoteEl = document.getElementById("accommodationNote");
const mealsCostEl = document.getElementById("mealsCost");
const mealsNoteEl = document.getElementById("mealsNote");
const incidentalsCostEl = document.getElementById("incidentalsCost");
const incidentalsNoteEl = document.getElementById("incidentalsNote");
// Event listeners
form.addEventListener("submit", handleFormSubmit);
form.addEventListener("reset", handleFormReset);
// Normalize region strings from database/API to the keys used in perDiemRatesDB
function normalizeRegion(region) {
if (!region) return "international";
const key = region.toLowerCase();
const map = {
europe: "international",
asia: "international",
africa: "international",
oceania: "international",
"middle east": "international",
middleeast: "international",
"south america": "international",
"central america": "international",
caribbean: "international",
};
if (map[key]) return map[key];
const allowed = new Set([
"canada",
"yukon",
"nwt",
"nunavut",
"usa",
"alaska",
"international",
]);
if (allowed.has(key)) return key;
return "international";
}
// Helper function to get allowances from database
function getAllowancesForRegion(destinationType) {
const regionKey = normalizeRegion(destinationType);
if (!perDiemRatesDB || !perDiemRatesDB.regions[regionKey]) {
console.warn(
`Region ${destinationType} not found in database, using international as default`
);
return {
breakfast: 29.05,
lunch: 29.6,
dinner: 60.75,
incidental: 17.3,
privateAccommodation: 50.0,
};
}
const region = perDiemRatesDB.regions[regionKey];
return {
breakfast: region.meals.breakfast.rate100,
lunch: region.meals.lunch.rate100,
dinner: region.meals.dinner.rate100,
incidental: region.incidentals.rate100,
privateAccommodation: region.privateAccommodation.day1to120,
currency: region.currency,
};
}
// Helper function to get accommodation rate suggestion
function getAccommodationSuggestion(destinationCity, destinationType) {
if (!accommodationRatesDB) return null;
// Normalize city name to match database key format
const normalizeCity = (city) => {
return city
.toLowerCase()
.replace(/,.*$/, "") // Remove everything after comma
.replace(/[^a-z\s]/g, "") // Remove special characters
.trim()
.replace(/\s+/g, ""); // Remove spaces
};
const cityKey = normalizeCity(destinationCity);
// Check standard cities
if (accommodationRatesDB.cities && accommodationRatesDB.cities[cityKey]) {
return accommodationRatesDB.cities[cityKey];
}
// Check international cities
if (
accommodationRatesDB.internationalCities &&
accommodationRatesDB.internationalCities[cityKey]
) {
return accommodationRatesDB.internationalCities[cityKey];
}
// Return default for region
if (
accommodationRatesDB.defaults &&
accommodationRatesDB.defaults[destinationType]
) {
return {
name: destinationCity,
...accommodationRatesDB.defaults[destinationType],
isDefault: true,
};
}
return null;
}
async function handleFormSubmit(e) {
e.preventDefault();
// Get form values
const departureCity = document.getElementById("departureCity").value.trim();
const destinationCity = document
.getElementById("destinationCity")
.value.trim();
// Validate cities are filled
if (!departureCity || !destinationCity) {
alert("Please enter both departure and destination cities.");
return;
}
// Validate both cities exist
const departureCityValid =
document.getElementById("departureCity").dataset.valid === "true";
const destinationCityValid =
document.getElementById("destinationCity").dataset.valid === "true";
if (!departureCityValid || !destinationCityValid) {
// Try to validate if not already done
if (!departureCityValid) {
alert(
`"${departureCity}" is not a valid city. Please check the spelling.`
);
return;
}
if (!destinationCityValid) {
alert(
`"${destinationCity}" is not a valid city. Please check the spelling.`
);
return;
}
}
const departureDate = new Date(
document.getElementById("departureDate").value
);
const returnDate = new Date(document.getElementById("returnDate").value);
const destinationType = document.getElementById("destinationType").value;
const transportMode = document.getElementById("transportMode").value;
// Get transport-specific values
let flightDuration = 0;
let estimatedTransportCost = 0;
let distanceKm = 0;
let customAllowances = null;
if (transportMode === "flight") {
flightDuration = parseFloat(
document.getElementById("flightDuration").value
);
estimatedTransportCost =
parseFloat(document.getElementById("estimatedFlightCost").value) || 0;
} else if (transportMode === "vehicle") {
distanceKm = parseFloat(document.getElementById("distanceKm").value);
} else if (transportMode === "train") {
estimatedTransportCost =
parseFloat(document.getElementById("estimatedTrainCost").value) || 0;
}
let accommodationPerNight = parseFloat(
document.getElementById("estimatedAccommodationPerNight").value
);
const privateAccommodation = document.getElementById(
"privateAccommodation"
).checked;
// Validate dates
if (returnDate <= departureDate) {
alert("Return date must be after departure date!");
return;
}
// Auto-lookup accommodation rate if not already populated
let destinationRegion = destinationType; // Start with user-selected type
if (!accommodationPerNight && !privateAccommodation) {
try {
const response = await fetch(
`/api/accommodation/rate?city=${encodeURIComponent(destinationCity)}`
);
const rateData = await response.json();
if (
rateData &&
!rateData.error &&
(rateData.accommodation || rateData.name)
) {
accommodationPerNight =
rateData.accommodation?.standard ||
rateData.accommodation?.monthly?.[0] ||
100;
destinationRegion = normalizeRegion(rateData.region || destinationType);
// Use city-specific allowances when provided by DB
if (rateData.meals) {
customAllowances = {
breakfast: rateData.meals.breakfast,
lunch: rateData.meals.lunch,
dinner: rateData.meals.dinner,
incidental: rateData.incidentals,
currency: rateData.currency,
privateAccommodation: 50.0,
};
}
document.getElementById("estimatedAccommodationPerNight").value =
accommodationPerNight.toFixed(2);
} else {
alert(
`Unable to find accommodation rate for "${destinationCity}". Please enter rate manually or check the city spelling.`
);
return;
}
} catch (error) {
console.error("Error fetching accommodation rate:", error);
alert("Error connecting to server. Please try again.");
return;
}
} else if (accommodationPerNight) {
// If accommodation is already populated, still try to detect the region for per diem rates
try {
const response = await fetch(
`/api/accommodation/rate?city=${encodeURIComponent(destinationCity)}`
);
const rateData = await response.json();
if (rateData && rateData.region) {
destinationRegion = normalizeRegion(rateData.region);
if (rateData.meals) {
customAllowances = {
breakfast: rateData.meals.breakfast,
lunch: rateData.meals.lunch,
dinner: rateData.meals.dinner,
incidental: rateData.incidentals,
currency: rateData.currency,
privateAccommodation: 50.0,
};
}
}
} catch (error) {
console.warn("Could not fetch region data:", error);
// Continue with user-selected type
}
}
// Calculate number of days
const timeDiff = returnDate.getTime() - departureDate.getTime();
const numberOfDays = Math.ceil(timeDiff / (1000 * 3600 * 24));
const numberOfNights = numberOfDays;
// Calculate costs with city-specific allowances if available
const costs = calculateCosts(
{
destinationType: destinationRegion, // Use detected region instead of dropdown selection
transportMode,
flightDuration,
estimatedTransportCost,
distanceKm,
accommodationPerNight,
numberOfDays,
numberOfNights,
privateAccommodation,
},
customAllowances // PASS the city-specific allowances here!
);
// Display results
displayResults(costs, {
departureCity,
destinationCity,
numberOfDays,
numberOfNights,
transportMode,
flightDuration,
distanceKm,
privateAccommodation,
});
}
function calculateCosts(params, customAllowances = null) {
const {
destinationType,
transportMode,
flightDuration,
estimatedTransportCost,
distanceKm,
accommodationPerNight,
numberOfDays,
numberOfNights,
privateAccommodation,
} = params;
// Use city-specific allowances if provided; otherwise fall back to region defaults
const allowances =
customAllowances || getAllowancesForRegion(destinationType);
// Calculate transportation cost
let transportCost = 0;
let transportNote = "";
let transportLabel = "đ Transportation";
if (transportMode === "flight") {
transportLabel = "âī¸ Flight Cost";
if (flightDuration >= BUSINESS_CLASS_THRESHOLD_HOURS) {
transportCost = estimatedTransportCost * BUSINESS_CLASS_MULTIPLIER;
transportNote = `Business class applicable (flight ${flightDuration} hours âĨ 9 hours). Estimated at ${BUSINESS_CLASS_MULTIPLIER}x economy cost per NJC Directive Section 3.3.11/3.4.11`;
}
} else if (transportMode === "vehicle") {
transportLabel = "đ Personal Vehicle";
const kmRate = transportationRatesDB
? transportationRatesDB.kilometricRates.modules.module3.rates.tier1.perKm
: 0.68;
transportCost = distanceKm * kmRate;
transportNote = `Kilometric rate: $${kmRate.toFixed(
2
)}/km à ${distanceKm} km. Rate from NJC Appendix B. Parking and tolls may be additional.`;
} else if (transportMode === "train") {
transportLabel = "đ Train Cost";
transportCost = estimatedTransportCost;
transportNote =
"Economy class estimate. Business class may be authorized with approval for extended travel or work requirements.";
}
// Calculate accommodation cost
let accommodationCost = 0;
let accommodationNote = "";
if (privateAccommodation) {
// Private non-commercial accommodation allowance
accommodationCost = allowances.privateAccommodation * numberOfNights;
accommodationNote = `Private accommodation allowance: $${allowances.privateAccommodation.toFixed(
2
)}/night à ${numberOfNights} nights`;
} else {
accommodationCost = accommodationPerNight * numberOfNights;
accommodationNote = `Hotel estimate: $${accommodationPerNight.toFixed(
2
)}/night à ${numberOfNights} nights. Verify rates at government accommodation directory`;
}
// Calculate meal allowances
const dailyMealAllowance =
allowances.breakfast + allowances.lunch + allowances.dinner;
const mealsCost = dailyMealAllowance * numberOfDays;
const mealsNote = `Daily meal allowance: Breakfast $${allowances.breakfast.toFixed(
2
)} + Lunch $${allowances.lunch.toFixed(
2
)} + Dinner $${allowances.dinner.toFixed(2)} = $${dailyMealAllowance.toFixed(
2
)} Ã ${numberOfDays} days`;
// Calculate incidental expenses
const incidentalsCost = allowances.incidental * numberOfDays;
const incidentalsNote = `Incidental allowance: $${allowances.incidental.toFixed(
2
)}/day à ${numberOfDays} days`;
// Calculate total
const totalCost =
transportCost + accommodationCost + mealsCost + incidentalsCost;
return {
transportCost,
transportNote,
transportLabel,
accommodationCost,
accommodationNote,
mealsCost,
mealsNote,
incidentalsCost,
incidentalsNote,
totalCost,
currency: allowances.currency || "CAD",
};
}
// Exchange rates (USD as base)
const EXCHANGE_RATES = {
CAD: 1.0,
USD: 0.72,
EUR: 0.92,
};
function convertCurrency(amount, fromCurrency, toCurrency) {
if (fromCurrency === toCurrency) return amount;
if (!EXCHANGE_RATES[fromCurrency] || !EXCHANGE_RATES[toCurrency]) {
return amount; // Return unchanged if rate not available
}
// Convert through USD as base
const amountInUSD = amount / EXCHANGE_RATES[fromCurrency];
return amountInUSD * EXCHANGE_RATES[toCurrency];
}
function formatCurrencyAmount(amount, currency, showSecondary = false) {
const primary = `${currency} ${amount.toFixed(2)}`;
if (!showSecondary || currency === "CAD") {
return primary;
}
const cadAmount = convertCurrency(amount, currency, "CAD");
return `${primary} (CAD ${cadAmount.toFixed(2)})`;
}
function displayResults(costs, travelInfo) {
// Update cost values with optional CAD conversion
const currencyLabel = costs.currency || "CAD";
const showCADConversion = currencyLabel !== "CAD";
totalCostEl.textContent = formatCurrencyAmount(
costs.totalCost,
currencyLabel,
showCADConversion
);
transportLabelEl.textContent = costs.transportLabel;
transportCostEl.textContent = formatCurrencyAmount(
costs.transportCost,
currencyLabel,
showCADConversion
);
transportNoteEl.textContent = costs.transportNote;
accommodationCostEl.textContent = formatCurrencyAmount(
costs.accommodationCost,
currencyLabel,
showCADConversion
);
accommodationNoteEl.textContent = costs.accommodationNote;
mealsCostEl.textContent = formatCurrencyAmount(
costs.mealsCost,
currencyLabel,
showCADConversion
);
mealsNoteEl.textContent = costs.mealsNote;
incidentalsCostEl.textContent = formatCurrencyAmount(
costs.incidentalsCost,
currencyLabel,
showCADConversion
);
incidentalsNoteEl.textContent = costs.incidentalsNote;
// Show results section
resultsSection.classList.remove("hidden");
// Smooth scroll to results
setTimeout(() => {
resultsSection.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, 100);
}
function handleFormReset() {
// Hide results section
resultsSection.classList.add("hidden");
}
// Validate city exists in database
async function validateCity(inputId) {
const input = document.getElementById(inputId);
const statusId =
inputId === "departureCity"
? "departureCityStatus"
: "destinationCityStatus";
const status = document.getElementById(statusId);
const city = input.value.trim();
console.log(`validateCity called for ${inputId}, city: "${city}"`);
if (!city) {
status.style.display = "none";
input.style.borderColor = "";
return;
}
try {
const url = `/api/accommodation/rate?city=${encodeURIComponent(city)}`;
console.log(`Fetching: ${url}`);
const response = await fetch(url);
const data = await response.json();
console.log(`API response:`, data);
if (data && !data.error && data.name) {
// Valid city
console.log(`â
Valid city: ${data.name}`);
status.textContent = `â
${data.name} found`;
status.style.color = "#2e7d32";
status.style.display = "block";
input.style.borderColor = "#2e7d32";
input.dataset.valid = "true";
} else {
// Invalid city
console.log(`â Invalid city`);
status.textContent = `â City not found. Check spelling or try a nearby major city`;
status.style.color = "#c62828";
status.style.display = "block";
input.style.borderColor = "#c62828";
input.dataset.valid = "false";
}
} catch (error) {
console.error("Validation error:", error);
status.style.display = "none";
}
}
// Simplified city suggestions function - called directly from HTML oninput
async function showCitySuggestions(query, suggestionsId, inputId) {
console.log(`showCitySuggestions: query="${query}", div="${suggestionsId}"`);
const suggestionsDiv = document.getElementById(suggestionsId);
if (!suggestionsDiv) {
console.error(`Suggestions div not found: ${suggestionsId}`);
return;
}
// Hide if query is too short
if (!query || query.length < 2) {
suggestionsDiv.style.display = "none";
return;
}
// Show loading
suggestionsDiv.innerHTML =
'Loading...
';
suggestionsDiv.style.display = "block";
try {
const response = await fetch(
`/api/autocomplete?q=${encodeURIComponent(query)}`
);
const data = await response.json();
console.log("API response:", data);
if (!data.suggestions || data.suggestions.length === 0) {
suggestionsDiv.innerHTML =
'