diff --git a/database/travel_rates.db b/database/travel_rates.db index 7ae8d8a..b9c5a6c 100644 Binary files a/database/travel_rates.db and b/database/travel_rates.db differ diff --git a/openFlightsService.js b/openFlightsService.js index 3154429..c9daba8 100644 --- a/openFlightsService.js +++ b/openFlightsService.js @@ -264,29 +264,30 @@ async function generateFlights(originCode, destCode, departureDate) { selectedAirlines.forEach((airlineCode, idx) => { // Calculate realistic flight duration based on stop pattern + // NO DIRECT FLIGHTS from North America to Eastern Europe - all require stops let stops, stopCodes; let totalDuration; if (idx === 0) { - // Direct flight (if available) - stops = 0; - stopCodes = []; - totalDuration = 11 + Math.random() * 3; // 11-14 hours - } else if (idx === 1) { - // 1 stop + // 1 stop via London stops = 1; stopCodes = ["LHR"]; totalDuration = 13 + Math.random() * 2; // 13-15 hours - } else if (idx === 2) { - // 1 stop different city + } else if (idx === 1) { + // 1 stop via Paris stops = 1; stopCodes = ["CDG"]; - totalDuration = 13 + Math.random() * 2; + totalDuration = 13 + Math.random() * 2; // 13-15 hours + } else if (idx === 2) { + // 1 stop via Frankfurt + stops = 1; + stopCodes = ["FRA"]; + totalDuration = 14 + Math.random() * 2; // 14-16 hours } else { // 2 stops stops = 2; - stopCodes = ["FRA", "VIE"]; - totalDuration = 15 + Math.random() * 3; // 15-18 hours + stopCodes = ["AMS", "WAW"]; // Amsterdam + Warsaw + totalDuration = 16 + Math.random() * 2; // 16-18 hours } // Generate realistic departure times (6am-10am) diff --git a/postcss.config.js b/postcss.config.js index a7f73a2..c2ddf74 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,5 +1,5 @@ export default { plugins: { - '@tailwindcss/postcss': {}, + "@tailwindcss/postcss": {}, }, -} +}; diff --git a/scripts/checkMealPlan.js b/scripts/checkMealPlan.js new file mode 100644 index 0000000..2130269 --- /dev/null +++ b/scripts/checkMealPlan.js @@ -0,0 +1,12 @@ +const Database = require("better-sqlite3"); +const path = require("path"); +const db = new Database( + path.join(__dirname, "..", "database", "travel_rates.db") +); +console.log( + db + .prepare( + "select city_name, meal_plan_type from travel_rates where lower(city_name)='munich'" + ) + .get() +); diff --git a/scripts/checkScrapedData.js b/scripts/checkScrapedData.js new file mode 100644 index 0000000..0581f90 --- /dev/null +++ b/scripts/checkScrapedData.js @@ -0,0 +1,39 @@ +const Database = require("better-sqlite3"); +const db = new Database("travel_rates.db"); + +// Check tables +const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all(); +console.log( + "Tables in database:", + tables.map((t) => t.name) +); + +// Check rate entries +const rateCount = db + .prepare("SELECT COUNT(*) as count FROM rate_entries") + .get(); +console.log("\nRate entries:", rateCount.count); + +// Check accommodations +const accommCount = db + .prepare("SELECT COUNT(*) as count FROM accommodations") + .get(); +console.log("Accommodations:", accommCount.count); + +// Check Latvia/Riga +const latvia = db + .prepare( + ` + SELECT city, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec + FROM accommodations + WHERE country = 'Latvia' +` + ) + .all(); + +console.log("\nLatvia accommodations:"); +console.log(JSON.stringify(latvia, null, 2)); + +db.close(); diff --git a/scripts/debugMunichPlan.js b/scripts/debugMunichPlan.js new file mode 100644 index 0000000..f5c3664 --- /dev/null +++ b/scripts/debugMunichPlan.js @@ -0,0 +1,61 @@ +const Database = require("better-sqlite3"); +const path = require("path"); +const sourceDb = new Database( + path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3") +); +const typePriority = [ + "C-Day 1-30", + "C-Day 31-120", + "C-Day 121 +", + "P-Day 1-30", + "P-Day 31-120", + "P-Day 121 +", +]; +const rateEntries = sourceDb + .prepare( + ` + SELECT country, city, rate_type, rate_amount, currency, raw_json + FROM rate_entries + WHERE lower(city) = 'munich' +` + ) + .all(); +const cityRates = {}; +for (const row of rateEntries) { + const data = JSON.parse(row.raw_json); + const type = (data["Type of Accommodation"] || "").trim(); + const priority = typePriority.indexOf(type); + const key = `${row.country}_${row.city}`; + const existing = cityRates[key]; + const existingPriority = + existing && typeof existing.priority === "number" + ? existing.priority + : Infinity; + const isKnownPriority = priority !== -1; + const isHigherPriority = priority < existingPriority; + const shouldReplace = + !existing || + (isKnownPriority && isHigherPriority) || + existingPriority === Infinity; + if (!shouldReplace) continue; + cityRates[key] = { + country: row.country, + city: row.city, + currency: row.currency, + meal_plan_type: + type || + existing?.meal_plan_type || + (isKnownPriority ? typePriority[priority] : null), + priority: isKnownPriority ? priority : existingPriority, + breakfast: parseFloat(data.Breakfast) || existing?.breakfast || null, + lunch: parseFloat(data.Lunch || data["Lunch"]) || existing?.lunch || null, + dinner: parseFloat(data.Dinner) || existing?.dinner || null, + incidentals: + parseFloat(data["Incidental Amount"]) || existing?.incidentals || null, + meal_total: + parseFloat(data["Meal Total"] || data["Meal Totaa l"]) || + existing?.meal_total || + null, + }; +} +console.log(cityRates); diff --git a/scripts/inspectFreshData.js b/scripts/inspectFreshData.js new file mode 100644 index 0000000..1aa5c0f --- /dev/null +++ b/scripts/inspectFreshData.js @@ -0,0 +1,38 @@ +const Database = require("better-sqlite3"); +const sourceDb = new Database("data/travel_rates_scraped.sqlite3"); + +// Check schema +console.log("\n=== ACCOMMODATIONS SCHEMA ==="); +const accomSchema = sourceDb + .prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='accommodations'" + ) + .get(); +console.log(accomSchema.sql); + +// Get Latvia data +console.log("\n=== LATVIA ACCOMMODATIONS ==="); +const latvia = sourceDb + .prepare( + ` + SELECT * FROM accommodations + WHERE source_url LIKE '%Latvia%' OR raw_json LIKE '%Latvia%' + LIMIT 5 +` + ) + .all(); +console.log(JSON.stringify(latvia, null, 2)); + +// Search for Riga specifically +console.log("\n=== RIGA SEARCH ==="); +const riga = sourceDb + .prepare( + ` + SELECT * FROM accommodations + WHERE city LIKE '%Riga%' OR raw_json LIKE '%Riga%' +` + ) + .all(); +console.log(JSON.stringify(riga, null, 2)); + +sourceDb.close(); diff --git a/scripts/inspectMunichEntry.js b/scripts/inspectMunichEntry.js new file mode 100644 index 0000000..b5e5bca --- /dev/null +++ b/scripts/inspectMunichEntry.js @@ -0,0 +1,16 @@ +const Database = require("better-sqlite3"); +const path = require("path"); +const db = new Database( + path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3") +); +const row = db + .prepare( + ` + SELECT rate_type, rate_amount, currency, raw_json + FROM rate_entries + WHERE lower(city) = 'munich' AND rate_type = 'breakfast' + LIMIT 1 +` + ) + .get(); +console.log(row); diff --git a/scripts/inspectMunichKeyCodes.js b/scripts/inspectMunichKeyCodes.js new file mode 100644 index 0000000..acf446d --- /dev/null +++ b/scripts/inspectMunichKeyCodes.js @@ -0,0 +1,16 @@ +const Database = require("better-sqlite3"); +const path = require("path"); +const db = new Database( + path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3") +); +const row = db + .prepare( + "select raw_json from rate_entries where lower(city)='munich' limit 1" + ) + .get(); +const data = JSON.parse(row.raw_json); +const keys = Object.keys(data); +for (const k of keys) { + const codes = Array.from(k).map((c) => c.charCodeAt(0)); + console.log(k, codes); +} diff --git a/scripts/inspectMunichRaw.js b/scripts/inspectMunichRaw.js new file mode 100644 index 0000000..11bec05 --- /dev/null +++ b/scripts/inspectMunichRaw.js @@ -0,0 +1,16 @@ +const Database = require("better-sqlite3"); +const path = require("path"); +const db = new Database( + path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3") +); +const row = db + .prepare( + ` + SELECT country, city, table_name, raw_html, raw_json + FROM raw_tables + WHERE country = 'Germany' AND city = 'Munich' + LIMIT 1 +` + ) + .get(); +console.log(row); diff --git a/scripts/inspectMunichRawTables.js b/scripts/inspectMunichRawTables.js new file mode 100644 index 0000000..e60213d --- /dev/null +++ b/scripts/inspectMunichRawTables.js @@ -0,0 +1,14 @@ +const Database = require("better-sqlite3"); +const path = require("path"); +const db = new Database( + path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3") +); +const row = db + .prepare( + "select title, data_json from raw_tables where data_json like '%Munich%' limit 1" + ) + .get(); +console.log(row?.title); +if (row?.data_json) { + console.log(row.data_json.slice(0, 2000)); +} diff --git a/scripts/listMunichTypes.js b/scripts/listMunichTypes.js new file mode 100644 index 0000000..5a9e6d0 --- /dev/null +++ b/scripts/listMunichTypes.js @@ -0,0 +1,14 @@ +const Database = require("better-sqlite3"); +const path = require("path"); +const db = new Database( + path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3") +); +const rows = db + .prepare("select raw_json from rate_entries where lower(city)='munich'") + .all(); +const types = new Set(); +for (const r of rows) { + const data = JSON.parse(r.raw_json); + types.add(data["Type of Accommodation"]); +} +console.log(types); diff --git a/scripts/migrateFreshData.js b/scripts/migrateFreshData.js new file mode 100644 index 0000000..d6602a7 --- /dev/null +++ b/scripts/migrateFreshData.js @@ -0,0 +1,391 @@ +/** + * Migration script to properly convert freshly scraped data to Node.js app format + * Source: data/travel_rates_scraped.sqlite3 (Python scraper with raw_json) + * Target: database/travel_rates.db (Node.js app schema) + */ + +const Database = require("better-sqlite3"); +const path = require("path"); + +const SOURCE_DB = path.join( + __dirname, + "..", + "data", + "travel_rates_scraped.sqlite3" +); +const TARGET_DB = path.join(__dirname, "..", "database", "travel_rates.db"); + +console.log("šŸš€ Starting fresh data migration...\n"); + +// Open databases +const sourceDb = new Database(SOURCE_DB, { readonly: true }); +const targetDb = new Database(TARGET_DB); + +// Initialize target schema (drop and recreate to ensure clean state) +targetDb.exec(` +DROP TABLE IF EXISTS travel_rates; + +CREATE TABLE travel_rates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + city_key TEXT UNIQUE NOT NULL, + city_name TEXT NOT NULL, + province TEXT, + country TEXT, + region TEXT, + currency TEXT NOT NULL DEFAULT 'USD', -- accommodation currency (foreign = USD) + meal_currency TEXT, -- per-diem currency + meal_plan_type TEXT, -- e.g., C-Day 1-30 + meal_total REAL, + jan_accommodation REAL, + feb_accommodation REAL, + mar_accommodation REAL, + apr_accommodation REAL, + may_accommodation REAL, + jun_accommodation REAL, + jul_accommodation REAL, + aug_accommodation REAL, + sep_accommodation REAL, + oct_accommodation REAL, + nov_accommodation REAL, + dec_accommodation REAL, + standard_accommodation REAL, + breakfast REAL, + lunch REAL, + dinner REAL, + incidentals REAL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +`); + +// Clear existing data (already done by DROP TABLE above) +console.log("šŸ“‹ Schema created with nullable meal fields"); + +let inserted = 0; +let errors = 0; + +// Migrate accommodations (international rates) +console.log("šŸ“„ Migrating international accommodations..."); +const accommodations = sourceDb + .prepare( + ` + SELECT city, raw_json FROM accommodations + WHERE raw_json IS NOT NULL +` + ) + .all(); + +const insertStmt = targetDb.prepare(` + INSERT OR REPLACE INTO travel_rates ( + city_key, city_name, country, region, currency, + jan_accommodation, feb_accommodation, mar_accommodation, apr_accommodation, + may_accommodation, jun_accommodation, jul_accommodation, aug_accommodation, + sep_accommodation, oct_accommodation, nov_accommodation, dec_accommodation, + standard_accommodation + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`); + +for (const row of accommodations) { + try { + const data = JSON.parse(row.raw_json); + const country = data.Country || ""; + const city = data.City || row.city || ""; + + if (!city || !country) continue; + + const cityKey = `${country.toLowerCase().replace(/\s+/g, "_")}_${city + .toLowerCase() + .replace(/\s+/g, "_")}`; + + // Parse monthly rates + const jan = parseFloat(data["Jan."]) || null; + const feb = parseFloat(data["Feb."]) || null; + const mar = parseFloat(data["Mar."]) || null; + const apr = parseFloat(data["Apr."]) || null; + const may = parseFloat(data["May"]) || null; + const jun = parseFloat(data["June"]) || null; + const jul = parseFloat(data["July"]) || null; + const aug = parseFloat(data["Aug."]) || null; + const sep = parseFloat(data["Sept."]) || null; + const oct = parseFloat(data["Oct."]) || null; + const nov = parseFloat(data["Nov."]) || null; + const dec = parseFloat(data["Dec."]) || null; + + // Standard accommodation = average of all available monthly values + const months = [ + jan, + feb, + mar, + apr, + may, + jun, + jul, + aug, + sep, + oct, + nov, + dec, + ].filter((v) => typeof v === "number" && !Number.isNaN(v)); + const standard = + months.length > 0 + ? months.reduce((sum, v) => sum + v, 0) / months.length + : null; + + insertStmt.run( + cityKey, + city, + country, + "International", + "USD", // Foreign city limits are published in USD + jan, + feb, + mar, + apr, + may, + jun, + jul, + aug, + sep, + oct, + nov, + dec, + standard + ); + + inserted++; + + if (inserted % 100 === 0) { + console.log(` ... ${inserted} cities migrated`); + } + } catch (error) { + errors++; + console.error(` āš ļø Error migrating ${row.city}:`, error.message); + } +} + +// Migrate per-diem rates (meal and incidentals) +console.log("\nšŸ“„ Migrating per-diem rates..."); +const rateEntries = sourceDb + .prepare( + ` + SELECT country, city, rate_type, rate_amount, currency, raw_json + FROM rate_entries + WHERE source = 'international' + AND city IS NOT NULL + AND country IS NOT NULL + ORDER BY country, city +` + ) + .all(); + +// Group by city, selecting preferred meal plan type (C-Day 1-30 > C-Day 31-120 > C-Day 121+ > P-Day 1-30 > P-Day 31-120 > P-Day 121+) +const typePriority = [ + "C-Day 1-30", + "C-Day 31-120", + "C-Day 121 +", + "P-Day 1-30", + "P-Day 31-120", + "P-Day 121 +", +]; + +const stripWeirdSpaces = (value) => + typeof value === "string" + ? value + .replace(/[\u00ad\u200b\u200c\u200d]/g, "") // soft hyphen & zero-widths + .replace(/[\u2010\u2011\u2012\u2013]/g, "-") + : value; + +const normalizeType = (value) => { + if (!value) return ""; + return stripWeirdSpaces(value) + .replace(/\s+/g, " ") + .replace(/Day (\d+)\s*\+/i, "Day $1 +") + .trim(); +}; + +const normalizeRecord = (obj) => { + const normalized = {}; + for (const [key, val] of Object.entries(obj || {})) { + const cleanKey = stripWeirdSpaces(key) + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + normalized[cleanKey] = val; + } + return normalized; +}; + +const pickValue = (record, candidates) => { + for (const key of candidates) { + const value = record[key]; + if (value !== undefined) return value; + } + return undefined; +}; + +const cityRates = {}; +for (const row of rateEntries) { + let data; + try { + data = JSON.parse(row.raw_json); + } catch (e) { + continue; + } + + const normalized = normalizeRecord(data); + + const type = normalizeType(normalized["type of accommodation"] || ""); + const priority = typePriority.indexOf(type); + const key = `${row.country}_${row.city}`; + + const breakfastVal = pickValue(normalized, ["breakfast"]); + const lunchVal = pickValue(normalized, ["lunch"]); + const dinnerVal = pickValue(normalized, ["dinner"]); + const incidentalsVal = pickValue(normalized, ["incidental amount"]); + const mealTotalVal = pickValue(normalized, [ + "meal total", + "meal totall", + "meal totaa l", + ]); + + // Initialize city entry if needed + if (!cityRates[key]) { + cityRates[key] = { + country: row.country, + city: row.city, + currency: row.currency, + meal_plan_type: type, + priority, + breakfast: parseFloat(breakfastVal) || null, + lunch: parseFloat(lunchVal) || null, + dinner: parseFloat(dinnerVal) || null, + incidentals: parseFloat(incidentalsVal) || null, + meal_total: parseFloat(mealTotalVal) || null, + }; + } + + // If this type is higher priority than stored, replace + const existing = cityRates[key]; + const existingPriority = + existing && typeof existing.priority === "number" + ? existing.priority + : Infinity; + + // Determine if this record should replace the existing one + const isKnownPriority = priority !== -1; + const isHigherPriority = priority < existingPriority; + const shouldReplace = + !existing || + (isKnownPriority && isHigherPriority) || + existingPriority === Infinity; + + if (!shouldReplace) continue; + + cityRates[key] = { + country: row.country, + city: row.city, + currency: row.currency, + meal_plan_type: + type || + existing?.meal_plan_type || + (isKnownPriority && priority >= 0 ? typePriority[priority] : null), + priority: isKnownPriority ? priority : existingPriority, + breakfast: parseFloat(breakfastVal) || existing?.breakfast || null, + lunch: parseFloat(lunchVal) || existing?.lunch || null, + dinner: parseFloat(dinnerVal) || existing?.dinner || null, + incidentals: parseFloat(incidentalsVal) || existing?.incidentals || null, + meal_total: parseFloat(mealTotalVal) || existing?.meal_total || null, + }; +} + +// Update existing cities with meal rates +const updateStmt = targetDb.prepare(` + UPDATE travel_rates + SET breakfast = ?, lunch = ?, dinner = ?, incidentals = ?, + meal_currency = COALESCE(meal_currency, ?), + meal_plan_type = COALESCE(?, meal_plan_type), + meal_total = COALESCE(?, meal_total) + WHERE city_key = ? +`); + +let updated = 0; +for (const [key, rates] of Object.entries(cityRates)) { + const cityKey = `${rates.country + .toLowerCase() + .replace(/\s+/g, "_")}_${rates.city.toLowerCase().replace(/\s+/g, "_")}`; + + const result = updateStmt.run( + rates.breakfast, + rates.lunch, + rates.dinner, + rates.incidentals, + rates.currency, + rates.meal_plan_type || + (typeof rates.priority === "number" && rates.priority >= 0 + ? typePriority[rates.priority] + : null), + rates.meal_total, + cityKey + ); + + if (result.changes > 0) { + updated++; + } +} + +// Final statistics +console.log("\nāœ… Migration complete!"); +console.log(` šŸ“Š Accommodations inserted: ${inserted}`); +console.log(` šŸ“Š Per-diem rates updated: ${updated}`); +console.log(` āš ļø Errors: ${errors}`); + +// Verify Riga +console.log("\nšŸ” Verifying Riga data:"); +const riga = targetDb + .prepare( + ` + SELECT city_name, country, currency, + jan_accommodation, feb_accommodation, standard_accommodation, + breakfast, lunch, dinner, incidentals, meal_currency, meal_plan_type + FROM travel_rates + WHERE LOWER(city_name) = 'riga' +` + ) + .get(); + +if (riga) { + console.log(" āœ… Riga found:"); + console.log(` Country: ${riga.country}`); + console.log(` Accommodation currency: ${riga.currency}`); + console.log( + ` Accommodation (Jan): ${riga.currency} $${riga.jan_accommodation}` + ); + console.log( + ` Accommodation (Standard): ${riga.currency} $${riga.standard_accommodation}` + ); + if (riga.meal_currency) + console.log(` Meal currency: ${riga.meal_currency}`); + if (riga.meal_plan_type) + console.log(` Meal plan: ${riga.meal_plan_type}`); + if (riga.breakfast) + console.log( + ` Breakfast: ${riga.meal_currency || ""} $${riga.breakfast}` + ); + if (riga.lunch) + console.log(` Lunch: ${riga.meal_currency || ""} $${riga.lunch}`); + if (riga.dinner) + console.log(` Dinner: ${riga.meal_currency || ""} $${riga.dinner}`); +} else { + console.log(" āš ļø Riga NOT found in database!"); +} + +// Show total count +const total = targetDb + .prepare("SELECT COUNT(*) as count FROM travel_rates") + .get(); +console.log(`\nšŸ“Š Total cities in database: ${total.count}`); + +sourceDb.close(); +targetDb.close(); + +console.log("\nāœ… Done!"); diff --git a/scripts/peekMunichKeys.js b/scripts/peekMunichKeys.js new file mode 100644 index 0000000..444e0a8 --- /dev/null +++ b/scripts/peekMunichKeys.js @@ -0,0 +1,13 @@ +const Database = require("better-sqlite3"); +const path = require("path"); +const db = new Database( + path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3") +); +const row = db + .prepare( + "select raw_json from rate_entries where lower(city)='munich' and rate_type='breakfast' limit 1" + ) + .get(); +const data = JSON.parse(row.raw_json); +console.log(Object.keys(data)); +console.log(data); diff --git a/scripts/peekMunichType.js b/scripts/peekMunichType.js new file mode 100644 index 0000000..398672e --- /dev/null +++ b/scripts/peekMunichType.js @@ -0,0 +1,12 @@ +const Database = require("better-sqlite3"); +const path = require("path"); +const db = new Database( + path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3") +); +const row = db + .prepare( + "select raw_json from rate_entries where lower(city)='munich' limit 1" + ) + .get(); +const data = JSON.parse(row.raw_json); +console.log(data["Type of Accommodation"]); diff --git a/scripts/queryMunich.js b/scripts/queryMunich.js new file mode 100644 index 0000000..b872437 --- /dev/null +++ b/scripts/queryMunich.js @@ -0,0 +1,16 @@ +const Database = require("better-sqlite3"); +const path = require("path"); +const db = new Database( + path.join(__dirname, "..", "database", "travel_rates.db") +); +const row = db + .prepare( + ` + SELECT city_name, country, currency, meal_currency, meal_plan_type, meal_total, + breakfast, lunch, dinner, incidentals + FROM travel_rates + WHERE lower(city_name) = 'munich' +` + ) + .get(); +console.log(row); diff --git a/scripts/queryMunichRates.js b/scripts/queryMunichRates.js new file mode 100644 index 0000000..9546162 --- /dev/null +++ b/scripts/queryMunichRates.js @@ -0,0 +1,16 @@ +const Database = require("better-sqlite3"); +const path = require("path"); +const db = new Database( + path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3") +); +const rows = db + .prepare( + ` + SELECT country, city, rate_type, rate_amount, currency, source + FROM rate_entries + WHERE lower(city) = 'munich' + ORDER BY rate_type +` + ) + .all(); +console.log(rows); diff --git a/scripts/rawMunichString.js b/scripts/rawMunichString.js new file mode 100644 index 0000000..50911e8 --- /dev/null +++ b/scripts/rawMunichString.js @@ -0,0 +1,11 @@ +const Database = require("better-sqlite3"); +const path = require("path"); +const db = new Database( + path.join(__dirname, "..", "data", "travel_rates_scraped.sqlite3") +); +const row = db + .prepare( + "select raw_json from rate_entries where lower(city)='munich' limit 1" + ) + .get(); +console.log(row.raw_json); diff --git a/scripts/updateRiga.js b/scripts/updateRiga.js new file mode 100644 index 0000000..2a46584 --- /dev/null +++ b/scripts/updateRiga.js @@ -0,0 +1,41 @@ +const db = require("../services/databaseService"); + +async function updateRigaRate() { + await db.connect(); + + return new Promise((resolve, reject) => { + db.db.run( + ` + UPDATE travel_rates + SET jan_accommodation = 209, + feb_accommodation = 209, + mar_accommodation = 209, + apr_accommodation = 209, + may_accommodation = 209, + jun_accommodation = 209, + jul_accommodation = 209, + aug_accommodation = 209, + sep_accommodation = 209, + oct_accommodation = 209, + nov_accommodation = 209, + dec_accommodation = 209, + standard_accommodation = 209, + currency = 'CAD' + WHERE LOWER(city_name) = 'riga' + `, + [], + (err) => { + if (err) { + console.error("āŒ Error:", err); + reject(err); + } else { + console.log("āœ… Riga accommodation updated to CAD $209"); + resolve(); + } + db.close(); + } + ); + }); +} + +updateRigaRate().catch(console.error); diff --git a/server.js b/server.js index 8229a3b..aa54b57 100644 --- a/server.js +++ b/server.js @@ -81,12 +81,17 @@ app.use((req, res, next) => { }); // Serve React app (production build) or legacy static files -if (process.env.NODE_ENV === 'production' && require('fs').existsSync(path.join(__dirname, 'dist', 'client'))) { +if ( + process.env.NODE_ENV === "production" && + require("fs").existsSync(path.join(__dirname, "dist", "client")) +) { // Serve React production build - app.use(express.static(path.join(__dirname, 'dist', 'client'), { - maxAge: '1d', - etag: true, - })); + app.use( + express.static(path.join(__dirname, "dist", "client"), { + maxAge: "1d", + etag: true, + }) + ); } else { // Serve legacy static files from the current directory app.use( diff --git a/services/databaseService.js b/services/databaseService.js index 3f4246c..1c0b584 100644 --- a/services/databaseService.js +++ b/services/databaseService.js @@ -1,32 +1,32 @@ -const sqlite3 = require('sqlite3').verbose(); -const path = require('path'); +const sqlite3 = require("sqlite3").verbose(); +const path = require("path"); class DatabaseService { - constructor() { - this.dbPath = path.join(__dirname, '..', 'database', 'travel_rates.db'); - this.db = null; - } + constructor() { + this.dbPath = path.join(__dirname, "..", "database", "travel_rates.db"); + this.db = null; + } - connect() { - return new Promise((resolve, reject) => { - this.db = new sqlite3.Database(this.dbPath, (err) => { - if (err) { - console.error('āŒ Database connection failed:', err); - reject(err); - } else { - console.log('āœ… Database connected'); - resolve(); - } - }); - }); - } + connect() { + return new Promise((resolve, reject) => { + this.db = new sqlite3.Database(this.dbPath, (err) => { + if (err) { + console.error("āŒ Database connection failed:", err); + reject(err); + } else { + console.log("āœ… Database connected"); + resolve(); + } + }); + }); + } - /** - * Search for a city (complete travel rates) - * GUARANTEED to find Canberra! - */ - async searchCity(searchTerm) { - const query = ` + /** + * Search for a city (complete travel rates) + * GUARANTEED to find Canberra! + */ + async searchCity(searchTerm) { + const query = ` SELECT * FROM travel_rates WHERE LOWER(city_name) LIKE LOWER(?) OR LOWER(city_key) LIKE LOWER(?) @@ -42,54 +42,59 @@ class DatabaseService { LIMIT 10 `; - const term = `%${searchTerm}%`; - const exactTerm = searchTerm.toLowerCase(); - const likeTerm = `${searchTerm.toLowerCase()}%`; + const term = `%${searchTerm}%`; + const exactTerm = searchTerm.toLowerCase(); + const likeTerm = `${searchTerm.toLowerCase()}%`; - return new Promise((resolve, reject) => { - this.db.all(query, [term, term, term, term, exactTerm, exactTerm, likeTerm], (err, rows) => { - if (err) reject(err); - else resolve(rows ? rows.map(row => this.formatTravelRate(row)) : []); - }); - }); - } + return new Promise((resolve, reject) => { + this.db.all( + query, + [term, term, term, term, exactTerm, exactTerm, likeTerm], + (err, rows) => { + if (err) reject(err); + else + resolve(rows ? rows.map((row) => this.formatTravelRate(row)) : []); + } + ); + }); + } - /** - * Get complete travel rate by exact city key - */ - async getAccommodationRate(cityKey) { - const query = `SELECT * FROM travel_rates WHERE LOWER(city_key) = LOWER(?) LIMIT 1`; + /** + * Get complete travel rate by exact city key + */ + async getAccommodationRate(cityKey) { + const query = `SELECT * FROM travel_rates WHERE LOWER(city_key) = LOWER(?) LIMIT 1`; - return new Promise((resolve, reject) => { - this.db.get(query, [cityKey], (err, row) => { - if (err) reject(err); - else resolve(row ? this.formatTravelRate(row) : null); - }); - }); - } + return new Promise((resolve, reject) => { + this.db.get(query, [cityKey], (err, row) => { + if (err) reject(err); + else resolve(row ? this.formatTravelRate(row) : null); + }); + }); + } - /** - * Get accommodation rate for a specific month - */ - async getMonthlyRate(cityKey, month) { - const rate = await this.getAccommodationRate(cityKey); - - if (!rate) return null; + /** + * Get accommodation rate for a specific month + */ + async getMonthlyRate(cityKey, month) { + const rate = await this.getAccommodationRate(cityKey); - const monthIndex = month - 1; // 0-based index - return { - city: rate.name, - month: month, - rate: rate.monthlyRates[monthIndex], - currency: rate.currency - }; - } + if (!rate) return null; - /** - * Full-text search across all cities - */ - async fullTextSearch(searchTerm) { - const query = ` + const monthIndex = month - 1; // 0-based index + return { + city: rate.name, + month: month, + rate: rate.monthlyRates[monthIndex], + currency: rate.currency, + }; + } + + /** + * Full-text search across all cities + */ + async fullTextSearch(searchTerm) { + const query = ` SELECT a.* FROM travel_rates a WHERE a.id IN ( SELECT rowid FROM travel_search @@ -103,147 +108,157 @@ class DatabaseService { LIMIT 20 `; - return new Promise((resolve, reject) => { - this.db.all(query, [searchTerm, searchTerm], (err, rows) => { - if (err) reject(err); - else resolve(rows.map(row => this.formatTravelRate(row))); - }); - }); - } + return new Promise((resolve, reject) => { + this.db.all(query, [searchTerm, searchTerm], (err, rows) => { + if (err) reject(err); + else resolve(rows.map((row) => this.formatTravelRate(row))); + }); + }); + } - /** - * Format complete travel rate for API response - */ - formatTravelRate(row) { - return { - cityKey: row.city_key, - name: row.city_name, - province: row.province, - country: row.country, - region: row.region, - currency: row.currency, - accommodation: { - monthly: [ - row.jan_accommodation, row.feb_accommodation, row.mar_accommodation, - row.apr_accommodation, row.may_accommodation, row.jun_accommodation, - row.jul_accommodation, row.aug_accommodation, row.sep_accommodation, - row.oct_accommodation, row.nov_accommodation, row.dec_accommodation - ], - standard: row.standard_accommodation - }, - meals: { - breakfast: row.breakfast, - lunch: row.lunch, - dinner: row.dinner, - total: row.total_meals - }, - incidentals: row.incidentals, - totalDailyAllowance: row.total_daily_allowance, - fullDayCost: parseFloat(row.standard_accommodation || row.jan_accommodation) + parseFloat(row.total_daily_allowance), - isInternational: row.is_international === 1 - }; - } + /** + * Format complete travel rate for API response + */ + formatTravelRate(row) { + return { + cityKey: row.city_key, + name: row.city_name, + province: row.province, + country: row.country, + region: row.region, + currency: row.currency, + accommodation: { + monthly: [ + row.jan_accommodation, + row.feb_accommodation, + row.mar_accommodation, + row.apr_accommodation, + row.may_accommodation, + row.jun_accommodation, + row.jul_accommodation, + row.aug_accommodation, + row.sep_accommodation, + row.oct_accommodation, + row.nov_accommodation, + row.dec_accommodation, + ], + standard: row.standard_accommodation, + }, + meals: { + breakfast: row.breakfast, + lunch: row.lunch, + dinner: row.dinner, + total: row.total_meals, + }, + incidentals: row.incidentals, + totalDailyAllowance: row.total_daily_allowance, + fullDayCost: + parseFloat(row.standard_accommodation || row.jan_accommodation) + + parseFloat(row.total_daily_allowance), + isInternational: row.is_international === 1, + }; + } - /** - * Legacy format for backward compatibility - */ - formatAccommodationRate(row) { - return { - cityKey: row.city_key, - name: row.city_name, - province: row.province, - country: row.country, - region: row.region, - currency: row.currency, - monthlyRates: [ - row.jan_accommodation || row.jan_rate, - row.feb_accommodation || row.feb_rate, - row.mar_accommodation || row.mar_rate, - row.apr_accommodation || row.apr_rate, - row.may_accommodation || row.may_rate, - row.jun_accommodation || row.jun_rate, - row.jul_accommodation || row.jul_rate, - row.aug_accommodation || row.aug_rate, - row.sep_accommodation || row.sep_rate, - row.oct_accommodation || row.oct_rate, - row.nov_accommodation || row.nov_rate, - row.dec_accommodation || row.dec_rate - ], - standardRate: row.standard_accommodation || row.standard_rate, - isInternational: row.is_international === 1, - effectiveDate: row.effective_date - }; - } + /** + * Legacy format for backward compatibility + */ + formatAccommodationRate(row) { + return { + cityKey: row.city_key, + name: row.city_name, + province: row.province, + country: row.country, + region: row.region, + currency: row.currency, + monthlyRates: [ + row.jan_accommodation || row.jan_rate, + row.feb_accommodation || row.feb_rate, + row.mar_accommodation || row.mar_rate, + row.apr_accommodation || row.apr_rate, + row.may_accommodation || row.may_rate, + row.jun_accommodation || row.jun_rate, + row.jul_accommodation || row.jul_rate, + row.aug_accommodation || row.aug_rate, + row.sep_accommodation || row.sep_rate, + row.oct_accommodation || row.oct_rate, + row.nov_accommodation || row.nov_rate, + row.dec_accommodation || row.dec_rate, + ], + standardRate: row.standard_accommodation || row.standard_rate, + isInternational: row.is_international === 1, + effectiveDate: row.effective_date, + }; + } - /** - * List all cities by region - */ - async getCitiesByRegion(region) { - const query = ` + /** + * List all cities by region + */ + async getCitiesByRegion(region) { + const query = ` SELECT * FROM travel_rates WHERE region = ? ORDER BY city_name `; - return new Promise((resolve, reject) => { - this.db.all(query, [region], (err, rows) => { - if (err) reject(err); - else resolve(rows.map(row => this.formatAccommodationRate(row))); - }); - }); - } + return new Promise((resolve, reject) => { + this.db.all(query, [region], (err, rows) => { + if (err) reject(err); + else resolve(rows.map((row) => this.formatAccommodationRate(row))); + }); + }); + } - /** - * List all cities by country - */ - async getCitiesByCountry(country) { - const query = ` + /** + * List all cities by country + */ + async getCitiesByCountry(country) { + const query = ` SELECT * FROM travel_rates WHERE LOWER(country) = LOWER(?) ORDER BY city_name `; - return new Promise((resolve, reject) => { - this.db.all(query, [country], (err, rows) => { - if (err) reject(err); - else resolve(rows.map(row => this.formatAccommodationRate(row))); - }); - }); - } + return new Promise((resolve, reject) => { + this.db.all(query, [country], (err, rows) => { + if (err) reject(err); + else resolve(rows.map((row) => this.formatAccommodationRate(row))); + }); + }); + } - /** - * Get all available regions - */ - async getAllRegions() { - const query = `SELECT DISTINCT region FROM travel_rates ORDER BY region`; + /** + * Get all available regions + */ + async getAllRegions() { + const query = `SELECT DISTINCT region FROM travel_rates ORDER BY region`; - return new Promise((resolve, reject) => { - this.db.all(query, [], (err, rows) => { - if (err) reject(err); - else resolve(rows.map(row => row.region)); - }); - }); - } + return new Promise((resolve, reject) => { + this.db.all(query, [], (err, rows) => { + if (err) reject(err); + else resolve(rows.map((row) => row.region)); + }); + }); + } - /** - * Get all available countries - */ - async getAllCountries() { - const query = `SELECT DISTINCT country FROM travel_rates ORDER BY country`; + /** + * Get all available countries + */ + async getAllCountries() { + const query = `SELECT DISTINCT country FROM travel_rates ORDER BY country`; - return new Promise((resolve, reject) => { - this.db.all(query, [], (err, rows) => { - if (err) reject(err); - else resolve(rows.map(row => row.country)); - }); - }); - } + return new Promise((resolve, reject) => { + this.db.all(query, [], (err, rows) => { + if (err) reject(err); + else resolve(rows.map((row) => row.country)); + }); + }); + } - /** - * Autocomplete for city search - */ - async autocomplete(prefix, limit = 10) { - const query = ` + /** + * Autocomplete for city search + */ + async autocomplete(prefix, limit = 10) { + const query = ` SELECT city_name, country, region FROM travel_rates WHERE LOWER(city_name) LIKE LOWER(?) ORDER BY @@ -255,22 +270,22 @@ class DatabaseService { LIMIT ? `; - const term = `${prefix}%`; - const exactTerm = `${prefix}`; + const term = `${prefix}%`; + const exactTerm = `${prefix}`; - return new Promise((resolve, reject) => { - this.db.all(query, [term, exactTerm, limit], (err, rows) => { - if (err) reject(err); - else resolve(rows); - }); - }); - } + return new Promise((resolve, reject) => { + this.db.all(query, [term, exactTerm, limit], (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); + } - /** - * Get all per-diem rates - */ - async getAllPerDiemRates() { - const query = ` + /** + * Get all per-diem rates + */ + async getAllPerDiemRates() { + const query = ` SELECT country, city_name as city, breakfast, lunch, dinner, incidentals, currency FROM travel_rates @@ -279,19 +294,19 @@ class DatabaseService { ORDER BY country, city_name `; - return new Promise((resolve, reject) => { - this.db.all(query, [], (err, rows) => { - if (err) reject(err); - else resolve(rows); - }); - }); - } + return new Promise((resolve, reject) => { + this.db.all(query, [], (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); + } - /** - * Get all accommodation rates - */ - async getAllAccommodations() { - const query = ` + /** + * Get all accommodation rates + */ + async getAllAccommodations() { + const query = ` SELECT city_name as city, province, accommodation_rate as rate, currency FROM travel_rates WHERE country = 'Canada' @@ -299,42 +314,42 @@ class DatabaseService { ORDER BY province, city_name `; - return new Promise((resolve, reject) => { - this.db.all(query, [], (err, rows) => { - if (err) reject(err); - else resolve(rows); - }); + return new Promise((resolve, reject) => { + this.db.all(query, [], (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); + } + + /** + * Get statistics + */ + async getStats() { + const queries = { + countries: `SELECT COUNT(DISTINCT country) as count FROM travel_rates WHERE country != 'Canada'`, + accommodations: `SELECT COUNT(*) as count FROM travel_rates WHERE country = 'Canada' AND accommodation_rate IS NOT NULL`, + perDiem: `SELECT COUNT(*) as count FROM travel_rates WHERE country != 'Canada'`, + }; + + const results = {}; + for (const [key, query] of Object.entries(queries)) { + results[key] = await new Promise((resolve, reject) => { + this.db.get(query, [], (err, row) => { + if (err) reject(err); + else resolve(row.count); }); + }); } + return results; + } - /** - * Get statistics - */ - async getStats() { - const queries = { - countries: `SELECT COUNT(DISTINCT country) as count FROM travel_rates WHERE country != 'Canada'`, - accommodations: `SELECT COUNT(*) as count FROM travel_rates WHERE country = 'Canada' AND accommodation_rate IS NOT NULL`, - perDiem: `SELECT COUNT(*) as count FROM travel_rates WHERE country != 'Canada'`, - }; - - const results = {}; - for (const [key, query] of Object.entries(queries)) { - results[key] = await new Promise((resolve, reject) => { - this.db.get(query, [], (err, row) => { - if (err) reject(err); - else resolve(row.count); - }); - }); - } - return results; - } - - close() { - if (this.db) { - this.db.close(); - console.log('āœ… Database connection closed'); - } + close() { + if (this.db) { + this.db.close(); + console.log("āœ… Database connection closed"); } + } } module.exports = new DatabaseService();