diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 1ecadad..0524986 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -12,7 +12,7 @@ jobs: preview-link: runs-on: ubuntu-latest steps: - - name: Post preview link + - name: Post preview links uses: actions/github-script@v7 with: script: | @@ -21,8 +21,8 @@ jobs: const repo = context.repo.repo; const pr = context.payload.pull_request; const sha = pr.head.sha; - const preview = `https://rawcdn.githack.com/${owner}/${repo}/${sha}/index.html`; - const body = `${marker}\n## 🌍 Web preview\n\n[Open this PR as a live website](${preview})\n\nPreview is pinned to commit \`${sha.slice(0, 7)}\` and updates automatically when the PR changes.`; + const root = `https://rawcdn.githack.com/${owner}/${repo}/${sha}`; + const body = `${marker}\n## 🌍 Web preview\n\n- [Open travel dashboard](${root}/index.html)\n- [Open live flight prices](${root}/flights.html)\n\nPreview is pinned to commit \`${sha.slice(0, 7)}\` and updates automatically when the PR changes.`; const comments = await github.paginate(github.rest.issues.listComments, { owner, @@ -36,17 +36,7 @@ jobs: ); if (existing) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existing.id, - body, - }); + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: pr.number, - body, - }); + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); } diff --git a/.github/workflows/update-flight-prices.yml b/.github/workflows/update-flight-prices.yml new file mode 100644 index 0000000..7bfc654 --- /dev/null +++ b/.github/workflows/update-flight-prices.yml @@ -0,0 +1,147 @@ +name: Update live flight prices + +on: + workflow_dispatch: + schedule: + # 07:17 in Vietnam (UTC+7). One refresh uses 4 SerpApi searches. + # Daily schedule keeps the project comfortably inside the 250-search free tier. + - cron: '17 0 * * *' + +permissions: + contents: write + pages: write + issues: write + +concurrency: + group: live-flight-prices-${{ github.ref }} + cancel-in-progress: false + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Check SerpApi secret + id: config + env: + SERPAPI_API_KEY: ${{ secrets.SERPAPI_API_KEY }} + run: | + if [ -z "$SERPAPI_API_KEY" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning::SERPAPI_API_KEY is not configured. Add it in Settings β†’ Secrets and variables β†’ Actions." + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + fi + + - name: Fetch Google Flights prices via SerpApi + if: steps.config.outputs.enabled == 'true' + env: + SERPAPI_API_KEY: ${{ secrets.SERPAPI_API_KEY }} + run: node scripts/fetch-flights.mjs + + - name: Evaluate price alert + if: steps.config.outputs.enabled == 'true' && github.ref_name == 'main' + env: + GH_TOKEN: ${{ github.token }} + ALERT_AMOUNT: ${{ vars.FLIGHT_ALERT_AMOUNT }} + ALERT_CURRENCY: ${{ vars.FLIGHT_ALERT_CURRENCY }} + shell: bash + run: | + if [ -z "$ALERT_AMOUNT" ]; then + echo "No FLIGHT_ALERT_AMOUNT repository variable configured; skipping GitHub Issue alert." + exit 0 + fi + + CURRENT_AMOUNT=$(node -p "require('./data/flights.json').cheapest?.offer?.total_amount || ''") + CURRENT_CURRENCY=$(node -p "require('./data/flights.json').cheapest?.offer?.total_currency || ''") + CURRENT_AIRLINE=$(node -p "require('./data/flights.json').cheapest?.offer?.owner?.name || 'Unknown airline'") + CURRENT_SCENARIO=$(node -p "require('./data/flights.json').cheapest?.label || 'China trip'") + CHECKED_AT=$(node -p "require('./data/flights.json').generated_at || new Date().toISOString()") + ALERT_CURRENCY=${ALERT_CURRENCY:-$CURRENT_CURRENCY} + + if [ -z "$CURRENT_AMOUNT" ] || [ -z "$CURRENT_CURRENCY" ]; then + echo "No cheapest live price found; skipping alert." + exit 0 + fi + + if [ "$CURRENT_CURRENCY" != "$ALERT_CURRENCY" ]; then + echo "::warning::Price alert currency is $ALERT_CURRENCY but Google Flights returned $CURRENT_CURRENCY. Alert comparison skipped." + exit 0 + fi + + TITLE="✈️ Flight price alert Β· China 2026" + ISSUE=$(gh issue list --state open --json number,title --jq '.[] | select(.title == "✈️ Flight price alert Β· China 2026") | .number' | head -n 1) + + if node -e "process.exit(Number(process.argv[1]) <= Number(process.argv[2]) ? 0 : 1)" "$CURRENT_AMOUNT" "$ALERT_AMOUNT"; then + BODY=$(cat < Search prices can change. Verify the itinerary and final booking price before paying. + EOF + ) + + if [ -n "$ISSUE" ]; then + gh issue edit "$ISSUE" --body "$BODY" + echo "Updated existing price alert issue #$ISSUE." + else + gh issue create --title "$TITLE" --body "$BODY" + echo "Created a new price alert issue." + fi + else + echo "Current price ${CURRENT_AMOUNT} ${CURRENT_CURRENCY} is above target ${ALERT_AMOUNT} ${ALERT_CURRENCY}." + if [ -n "$ISSUE" ]; then + gh issue close "$ISSUE" --comment "Latest price moved back above the target: ${CURRENT_AMOUNT} ${CURRENT_CURRENCY} (target ${ALERT_AMOUNT} ${ALERT_CURRENCY}). The next target hit can create a fresh alert." + fi + fi + + - name: Commit refreshed price snapshot + if: steps.config.outputs.enabled == 'true' + id: commit + run: | + if git diff --quiet -- data/flights.json data/flight-history.json; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No flight price changes to commit." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add data/flights.json data/flight-history.json + git commit -m "chore: refresh Google Flights prices [skip ci]" + git push origin "HEAD:${GITHUB_REF_NAME}" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Request GitHub Pages rebuild + if: steps.config.outputs.enabled == 'true' && steps.commit.outputs.changed == 'true' && github.ref_name == 'main' + env: + GH_TOKEN: ${{ github.token }} + run: | + curl --fail-with-body -L \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/pages/builds" diff --git a/README.md b/README.md index 446ea54..ec8985a 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,145 @@ # Personal Travel Log -A lightweight personal travel dashboard built for GitHub Pages. It works as a static site, stores personal planning data locally in the browser, and can also behave like a small installable web app. +A lightweight personal travel dashboard for GitHub Pages with itinerary, budget, notes, PWA support and automated Google Flights price tracking. ## Features -- Responsive desktop and mobile layout -- Live countdown to the next departure -- Visual trip route and overview statistics -- Trip essentials: flights, hotels, internet and map shortcuts -- Expandable day-by-day itinerary -- Upcoming destination cards -- Pre-trip checklist with completion percentage -- Budget tracker with planned / actual / remaining totals -- Personal trip notes saved automatically in `localStorage` -- Export / import local travel data as JSON -- Native share button when supported -- Light / dark mode -- Mobile bottom navigation -- PWA manifest + service worker for install/offline use -- No framework, database or build process required +- Responsive desktop/mobile travel dashboard +- Countdown, route, itinerary and destination cards +- Checklist, budget and notes saved in `localStorage` +- Export/import local travel data +- Light/dark mode and PWA/offline support +- **Google Flights price snapshots through SerpApi + GitHub Actions** +- Return-date comparison, airline/stops filters, price history and price alerts +- No separate backend server -## Files +## Current China trip + +- Ho Chi Minh City β†’ Shanghai β†’ Beijing +- Outbound: **20 October 2026** +- Return options: **25 October evening** or **26 October morning** +- **6 adults + 1 infant under 2 on lap** +- Economy +- Direct or maximum 1 stop per leg + +## Live flight prices + +The workflow `.github/workflows/update-flight-prices.yml` calls SerpApi's Google Flights engine. The API key remains in GitHub Actions Secrets and is never exposed in the browser. + +Google Flights multi-city selection is sequential. For each return-date scenario the fetcher performs: + +1. initial multi-city search to get the first-leg options and a `departure_token`, +2. a second search with that token to get the next leg and complete itinerary prices. + +There are two scenarios, so one refresh uses **4 SerpApi searches**. + +### 1. Create a SerpApi key + +Create a SerpApi account and copy your private API key. + +### 2. Add the GitHub Actions secret + +Repository β†’ **Settings β†’ Secrets and variables β†’ Actions β†’ New repository secret** ```text -trip/ -β”œβ”€β”€ index.html -β”œβ”€β”€ manifest.webmanifest -β”œβ”€β”€ sw.js -β”œβ”€β”€ icon.svg -β”œβ”€β”€ .nojekyll -└── README.md +Name: SERPAPI_API_KEY +Value: ``` -## Current trip +Do not add the key to source code, repository variables, `flights.json`, issues or PR comments. -The starter data is configured for the China trip in October 2026: +### 3. Merge the PR and run the first check -- Ho Chi Minh City β†’ Shanghai β†’ Beijing -- 20–26 October 2026 -- Day-by-day itinerary included in `index.html` +After the workflow exists on `main`: + +```text +Actions +β†’ Update live flight prices +β†’ Run workflow +``` + +The workflow writes: + +```text +data/flights.json +data/flight-history.json +``` + +and commits refreshed snapshots back to `main`. -The site intentionally keeps travel data in plain HTML/JavaScript so it is easy to edit directly from GitHub without a build pipeline. +### Automatic refresh -## Personal data +The default schedule is: -Checklist, budget and notes are stored in the browser using `localStorage` under: +```text +07:17 Asia/Ho_Chi_Minh +``` + +One refresh uses 4 API searches, so a 30-day month is roughly **120 searches**, leaving room for manual checks within SerpApi's free quota. + +### Price dashboard + +`flights.html` provides: + +- comparison of returning **25 vs 26 October** +- Cheapest / Fastest sorting +- Direct only / 1 stop filters +- airline filter +- total search price for the selected 7 travellers +- rough total Γ· 7 reference value +- price change from the previous check +- lowest/highest saved prices +- saved trend chart +- Fresh / Aging / Stale indicator +- browser-local target price +- shortcut to run GitHub Actions manually + +## Optional GitHub Issue price alert + +Create repository Actions variables: ```text -travel-log-v2 +FLIGHT_ALERT_AMOUNT=30000000 +FLIGHT_ALERT_CURRENCY=VND ``` -Use **Export data** before changing browsers/devices. The exported JSON file can later be restored with **Import data**. +When the current cheapest total is at or below the threshold, the workflow opens or updates: + +```text +✈️ Flight price alert Β· China 2026 +``` -## Run locally +When the price moves above the target again, the issue is closed. + +## Price notes + +The results are Google Flights search snapshots, not locked fares. Google Flights may omit some carriers/options and final seller prices can change. Baggage, card and other optional fees may be additional. Always verify the itinerary and final amount on Google Flights or the airline/agency before paying. + +## Files + +```text +trips/ +β”œβ”€β”€ .github/workflows/ +β”‚ β”œβ”€β”€ pr-preview.yml +β”‚ └── update-flight-prices.yml +β”œβ”€β”€ data/ +β”‚ β”œβ”€β”€ flights.json +β”‚ └── flight-history.json +β”œβ”€β”€ scripts/ +β”‚ └── fetch-flights.mjs +β”œβ”€β”€ flights/ +β”‚ └── index.html +β”œβ”€β”€ flights.html +β”œβ”€β”€ index.html +β”œβ”€β”€ manifest.webmanifest +β”œβ”€β”€ sw.js +β”œβ”€β”€ icon.svg +β”œβ”€β”€ CNAME +β”œβ”€β”€ .nojekyll +└── README.md +``` + +## Local development ```bash python3 -m http.server 8080 @@ -61,22 +148,19 @@ python3 -m http.server 8080 Open: ```text -http://localhost:8080 +http://localhost:8080/ +http://localhost:8080/flights.html ``` -Using a local server is recommended when testing the service worker and PWA behavior. - ## GitHub Pages -Repository Settings β†’ Pages: - ```text Source: Deploy from a branch Branch: main Folder: / (root) ``` -The `.nojekyll` file keeps GitHub Pages in simple static-site mode. +The refresh workflow explicitly requests a Pages rebuild after committing price data because a commit pushed by a workflow `GITHUB_TOKEN` does not itself trigger another Pages build. --- diff --git a/data/flight-history.json b/data/flight-history.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/data/flight-history.json @@ -0,0 +1 @@ +[] diff --git a/data/flights.json b/data/flights.json new file mode 100644 index 0000000..eb16e3d --- /dev/null +++ b/data/flights.json @@ -0,0 +1,22 @@ +{ + "status": "setup_required", + "provider": "SerpApi", + "source": "Google Flights", + "generated_at": null, + "live_mode": false, + "disclaimer": "Add the SERPAPI_API_KEY repository secret, merge the PR, and run the Update live flight prices workflow to populate Google Flights prices.", + "search": { + "passengers": { + "adults": 6, + "infantsOnLap": 1, + "label": "6 adults + 1 infant (<2, on lap)" + }, + "cabin_class": "economy", + "max_connections": 1, + "currency": "VND", + "searches_per_refresh": 4, + "route_label": "SGN β†’ Shanghai Β· Beijing β†’ SGN" + }, + "scenarios": [], + "cheapest": null +} diff --git a/flights.html b/flights.html new file mode 100644 index 0000000..e164025 --- /dev/null +++ b/flights.html @@ -0,0 +1,53 @@ + + + + + + + + Flight Price Watch Β· Travel Log + + + +
+
+
+
Google Flights Β· SerpApi

Flight price watch

Google Flights search snapshots for the China trip. GitHub Actions refreshes prices automatically while the SerpApi key stays private in GitHub Secrets.

SGN β†’ ShanghaiBeijing β†’ SGN6 adults + 1 infant on lapEconomyDirect / max 1 stop
+ +
+
At a glance

Decision summary

Calculated from the latest saved snapshot
β€”best return option
β€”rough avg / traveller
β€”best saved price
β€”change vs previous
+
25 or 26 October?

Return date comparison

Cheapest matching itinerary in each scenario
Waiting for live Google Flights data…
+
Explore offers

Current offers

Reading data/flights.json…
+
+
Loading flight offers…
+
+
Price tracking

Trend & recent range

One point per saved cheapest check
Cheapest saved trendWaiting for history…
β€”checks
β€”lowest
β€”highest
β€”latest airline
+
Important: prices come from a Google Flights search performed through SerpApi with 6 adults and 1 infant on lap. Google states that the displayed flight price is the total cost for every flight on the selected ticket, while baggage, card or other optional fees can still apply. Always open Google Flights and verify the final itinerary and amount before paying.
+
+
Β© Travel Log Β· Google Flights data via SerpApi
+ + + diff --git a/flights/index.html b/flights/index.html new file mode 100644 index 0000000..f71ec1a --- /dev/null +++ b/flights/index.html @@ -0,0 +1 @@ +Flight Prices

Open live flight prices

diff --git a/scripts/fetch-flights.mjs b/scripts/fetch-flights.mjs new file mode 100644 index 0000000..18a29b8 --- /dev/null +++ b/scripts/fetch-flights.mjs @@ -0,0 +1,363 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const API_KEY = process.env.SERPAPI_API_KEY; +if (!API_KEY) { + console.error('Missing SERPAPI_API_KEY. Add it as a GitHub Actions repository secret.'); + process.exit(2); +} + +const API = 'https://serpapi.com/search.json'; +const OUT = path.resolve('data/flights.json'); +const HISTORY = path.resolve('data/flight-history.json'); + +const SEARCH = { + passengers: { + adults: 6, + infantsOnLap: 1, + label: '6 adults + 1 infant (<2, on lap)' + }, + travelClass: 1, + cabinClass: 'economy', + stops: 2, + maxConnections: 1, + currency: 'VND', + scenarios: [ + { + id: 'return-25', + label: 'Return 25 Oct Β· evening preferred', + legs: [ + { departure_id: 'SGN', arrival_id: 'SHA,PVG', date: '2026-10-20' }, + { departure_id: 'PEK,PKX', arrival_id: 'SGN', date: '2026-10-25' } + ], + returnWindow: { afterHour: 17 } + }, + { + id: 'return-26', + label: 'Return 26 Oct Β· morning preferred', + legs: [ + { departure_id: 'SGN', arrival_id: 'SHA,PVG', date: '2026-10-20' }, + { departure_id: 'PEK,PKX', arrival_id: 'SGN', date: '2026-10-26' } + ], + returnWindow: { beforeHour: 12 } + } + ] +}; + +async function serpapi(params) { + const url = new URL(API); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, String(value)); + } + }); + url.searchParams.set('api_key', API_KEY); + + const response = await fetch(url, { headers: { Accept: 'application/json' } }); + const text = await response.text(); + + let body; + try { body = text ? JSON.parse(text) : {}; } + catch { body = { raw: text }; } + + if (!response.ok || body?.error) { + throw new Error(`SerpApi request failed: ${body?.error || `HTTP ${response.status}`}`); + } + + if (body?.search_metadata?.status && body.search_metadata.status !== 'Success') { + throw new Error(`SerpApi search did not complete successfully: ${body.search_metadata.status}`); + } + + return body; +} + +function baseParams(scenario) { + return { + engine: 'google_flights', + type: 3, + multi_city_json: JSON.stringify(scenario.legs), + adults: SEARCH.passengers.adults, + infants_on_lap: SEARCH.passengers.infantsOnLap, + travel_class: SEARCH.travelClass, + stops: SEARCH.stops, + currency: SEARCH.currency, + hl: 'en', + gl: 'vn', + sort_by: 2 + }; +} + +function allResults(body) { + return [ + ...(Array.isArray(body?.best_flights) ? body.best_flights : []), + ...(Array.isArray(body?.other_flights) ? body.other_flights : []) + ].filter(x => Number.isFinite(Number(x?.price))); +} + +function minutesToIso(minutes) { + const n = Number(minutes); + if (!Number.isFinite(n) || n < 0) return null; + const hours = Math.floor(n / 60); + const mins = Math.round(n % 60); + return `PT${hours ? `${hours}H` : ''}${mins ? `${mins}M` : (!hours ? '0M' : '')}`; +} + +function timeToIso(value) { + if (!value) return null; + const raw = String(value).trim(); + if (/^\d{4}-\d{2}-\d{2}T/.test(raw)) return raw; + const match = raw.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{1,2}):(\d{2})$/); + if (match) return `${match[1]}T${match[2].padStart(2, '0')}:${match[3]}:00`; + return raw; +} + +function localHour(value) { + if (!value) return null; + const match = String(value).match(/[T\s](\d{1,2}):/); + if (!match) return null; + const hour = Number(match[1]); + return Number.isFinite(hour) ? hour : null; +} + +function compactSegment(flight) { + const airline = flight.airline || flight.operated_by || 'Airline'; + return { + origin: flight.departure_airport?.id || flight.departure_airport?.name || null, + destination: flight.arrival_airport?.id || flight.arrival_airport?.name || null, + departing_at: timeToIso(flight.departure_airport?.time), + arriving_at: timeToIso(flight.arrival_airport?.time), + duration: minutesToIso(flight.duration), + flight_number: flight.flight_number || null, + airplane: flight.airplane || null, + travel_class: flight.travel_class || null, + marketing_carrier: { + name: airline, + iata_code: String(flight.flight_number || '').replace(/\s+/g, '').match(/^([A-Z0-9]{2})/)?.[1] || null + }, + operating_carrier: { + name: flight.operated_by || airline, + iata_code: null + } + }; +} + +function compactLeg(raw, fallback) { + const flights = Array.isArray(raw?.flights) ? raw.flights : []; + if (!flights.length) return null; + + return { + origin: flights[0]?.departure_airport?.id || fallback?.departure_id || null, + destination: flights.at(-1)?.arrival_airport?.id || fallback?.arrival_id || null, + duration: minutesToIso(raw.total_duration || flights.reduce((sum, f) => sum + (Number(f.duration) || 0), 0)), + segments: flights.map(compactSegment) + }; +} + +function airlineNames(raw) { + return [...new Set((raw?.flights || []).map(f => f.airline || f.operated_by).filter(Boolean))]; +} + +function ownerFor(outbound, returning) { + const names = [...new Set([...airlineNames(outbound), ...airlineNames(returning)])]; + const firstFlight = outbound?.flights?.[0] || returning?.flights?.[0]; + return { + name: names.length === 1 ? names[0] : names.length > 1 ? 'Mixed airlines' : 'Airline', + iata_code: String(firstFlight?.flight_number || '').replace(/\s+/g, '').match(/^([A-Z0-9]{2})/)?.[1] || null, + logo_symbol_url: returning?.airline_logo || outbound?.airline_logo || firstFlight?.airline_logo || null + }; +} + +function compactOffer(outbound, returning, scenario, body, index) { + const outboundSlice = compactLeg(outbound, scenario.legs[0]); + const returnSlice = compactLeg(returning, scenario.legs[1]); + if (!outboundSlice || !returnSlice) return null; + + return { + id: returning.booking_token || `${scenario.id}-${index}-${returning.price}`, + source: 'Google Flights via SerpApi', + live_mode: true, + expires_at: null, + total_amount: String(returning.price), + total_currency: SEARCH.currency, + base_amount: null, + tax_amount: null, + total_emissions_kg: null, + total_duration_minutes: (Number(outbound.total_duration) || 0) + (Number(returning.total_duration) || 0), + booking_token: returning.booking_token || null, + google_flights_url: body?.search_metadata?.google_flights_url || null, + owner: ownerFor(outbound, returning), + slices: [outboundSlice, returnSlice] + }; +} + +function preferredReturn(offer, scenario) { + const hour = localHour(offer?.slices?.[1]?.segments?.[0]?.departing_at); + if (hour === null || !scenario.returnWindow) return true; + if (scenario.returnWindow.afterHour !== undefined && hour < scenario.returnWindow.afterHour) return false; + if (scenario.returnWindow.beforeHour !== undefined && hour >= scenario.returnWindow.beforeHour) return false; + return true; +} + +function dedupeOffers(items) { + const seen = new Set(); + return items.filter(offer => { + const key = [ + offer.total_amount, + ...offer.slices.flatMap(slice => + slice.segments.map(seg => `${seg.flight_number || ''}:${seg.departing_at || ''}`) + ) + ].join('|'); + + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +async function searchScenario(scenario) { + console.log(`Searching ${scenario.label}...`); + + // Google Flights multi-city selection is sequential in SerpApi. + // First request returns the first-leg choices + departure_token. + // Second request selects that first leg and returns the next leg + total itinerary prices. + const initial = await serpapi(baseParams(scenario)); + const outboundCandidates = allResults(initial) + .filter(x => x.departure_token) + .sort((a, b) => Number(a.price) - Number(b.price)); + + const outbound = outboundCandidates[0]; + if (!outbound) { + return { + id: scenario.id, + label: scenario.label, + search_ids: [initial?.search_metadata?.id].filter(Boolean), + google_flights_url: initial?.search_metadata?.google_flights_url || null, + slices: scenario.legs.map(leg => ({ origin: leg.departure_id, destination: leg.arrival_id, departure_date: leg.date })), + preferred_window_matched: false, + offers: [], + offer_count_seen: 0, + warning: 'Google Flights returned no selectable outbound flight.' + }; + } + + const next = await serpapi({ + ...baseParams(scenario), + departure_token: outbound.departure_token + }); + + const selectedOutbound = Array.isArray(next.selected_flights) && next.selected_flights.length + ? next.selected_flights[0] + : outbound; + + const returningCandidates = allResults(next) + .filter(x => x.booking_token || Number.isFinite(Number(x.price))) + .sort((a, b) => Number(a.price) - Number(b.price)); + + const normalized = dedupeOffers( + returningCandidates + .map((returning, index) => compactOffer(selectedOutbound, returning, scenario, next, index)) + .filter(Boolean) + .sort((a, b) => Number(a.total_amount) - Number(b.total_amount)) + ); + + const preferred = normalized.filter(offer => preferredReturn(offer, scenario)); + const selected = (preferred.length ? preferred : normalized).slice(0, 12); + + return { + id: scenario.id, + label: scenario.label, + search_ids: [initial?.search_metadata?.id, next?.search_metadata?.id].filter(Boolean), + google_flights_url: next?.search_metadata?.google_flights_url || initial?.search_metadata?.google_flights_url || null, + slices: scenario.legs.map(leg => ({ + origin: leg.departure_id, + destination: leg.arrival_id, + departure_date: leg.date + })), + selected_outbound: { + airline: selectedOutbound?.flights?.[0]?.airline || null, + departure: selectedOutbound?.flights?.[0]?.departure_airport?.time || null, + arrival: selectedOutbound?.flights?.at(-1)?.arrival_airport?.time || null, + price_hint: selectedOutbound?.price ?? null + }, + preferred_window_matched: preferred.length > 0, + offers: selected, + offer_count_seen: normalized.length, + price_insights: next?.price_insights || null + }; +} + +async function readJson(file, fallback) { + try { return JSON.parse(await fs.readFile(file, 'utf8')); } + catch { return fallback; } +} + +const previous = await readJson(OUT, null); +const oldHistory = await readJson(HISTORY, []); +const generatedAt = new Date().toISOString(); + +const scenarios = []; +for (const scenario of SEARCH.scenarios) { + scenarios.push(await searchScenario(scenario)); +} + +for (const scenario of scenarios) { + const current = scenario.offers[0]; + const previousScenario = previous?.scenarios?.find?.(s => s.id === scenario.id); + const old = previousScenario?.offers?.[0]; + + if (current && old && current.total_currency === old.total_currency) { + current.previous_total_amount = old.total_amount; + current.price_delta = (Number(current.total_amount) - Number(old.total_amount)).toFixed(0); + } +} + +const allCheapest = scenarios + .map(s => ({ scenario_id: s.id, label: s.label, offer: s.offers[0] })) + .filter(x => x.offer) + .sort((a, b) => Number(a.offer.total_amount) - Number(b.offer.total_amount)); + +const result = { + status: allCheapest.length ? 'ok' : 'no_results', + provider: 'SerpApi', + source: 'Google Flights', + generated_at: generatedAt, + live_mode: true, + disclaimer: allCheapest.length + ? 'Google Flights multi-city search snapshot for the selected 7 travellers. Fares can change and baggage/payment fees may apply.' + : 'SerpApi completed successfully but Google Flights returned no comparable itinerary for the configured routes.', + search: { + passengers: SEARCH.passengers, + cabin_class: SEARCH.cabinClass, + max_connections: SEARCH.maxConnections, + currency: SEARCH.currency, + searches_per_refresh: SEARCH.scenarios.length * 2, + route_label: 'SGN β†’ Shanghai Β· Beijing β†’ SGN' + }, + scenarios, + cheapest: allCheapest[0] || null +}; + +const historyRows = scenarios.flatMap(s => { + const o = s.offers[0]; + return o ? [{ + checked_at: generatedAt, + scenario_id: s.id, + total_amount: o.total_amount, + total_currency: o.total_currency, + airline: o.owner?.name || null, + provider: 'SerpApi', + source: 'Google Flights' + }] : []; +}); + +const history = [...(Array.isArray(oldHistory) ? oldHistory : []), ...historyRows].slice(-360); + +await fs.mkdir(path.dirname(OUT), { recursive: true }); +await fs.writeFile(OUT, JSON.stringify(result, null, 2) + '\n'); +await fs.writeFile(HISTORY, JSON.stringify(history, null, 2) + '\n'); + +console.log(`Saved ${OUT}`); +for (const s of scenarios) { + const o = s.offers[0]; + console.log(`${s.label}: ${o ? `${o.total_amount} ${o.total_currency} Β· ${o.owner?.name || 'airline'}` : 'no offers'}`); +} diff --git a/sw.js b/sw.js index 7c56c81..a88729b 100644 --- a/sw.js +++ b/sw.js @@ -1,5 +1,5 @@ -const CACHE='travel-log-v2'; -const ASSETS=['./','./index.html','./manifest.webmanifest','./icon.svg']; +const CACHE='travel-log-v3'; +const ASSETS=['./','./index.html','./flights.html','./manifest.webmanifest','./icon.svg']; self.addEventListener('install',event=>{event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(ASSETS)));self.skipWaiting()}); self.addEventListener('activate',event=>{event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(key=>key!==CACHE).map(key=>caches.delete(key)))));self.clients.claim()}); self.addEventListener('fetch',event=>{if(event.request.method!=='GET')return;event.respondWith(fetch(event.request).then(response=>{const copy=response.clone();caches.open(CACHE).then(cache=>cache.put(event.request,copy));return response}).catch(()=>caches.match(event.request).then(cached=>cached||caches.match('./index.html'))))});