From 656ef5707101a473c0840453875d80c06955347e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 06:26:09 +0700 Subject: [PATCH 01/17] Add Duffel live flight price fetcher --- scripts/fetch-flights.mjs | 242 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 scripts/fetch-flights.mjs diff --git a/scripts/fetch-flights.mjs b/scripts/fetch-flights.mjs new file mode 100644 index 0000000..ace5f5d --- /dev/null +++ b/scripts/fetch-flights.mjs @@ -0,0 +1,242 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const TOKEN = process.env.DUFFEL_ACCESS_TOKEN; +if (!TOKEN) { + console.error('Missing DUFFEL_ACCESS_TOKEN. Add it as a GitHub Actions repository secret.'); + process.exit(2); +} + +const API = 'https://api.duffel.com'; +const OUT = path.resolve('data/flights.json'); +const HISTORY = path.resolve('data/flight-history.json'); + +const SEARCH = { + passengers: { + adults: 6, + infantAge: 1, + label: '6 adults + 1 infant (<2)' + }, + cabinClass: 'economy', + maxConnections: 1, + scenarios: [ + { + id: 'return-25', + label: 'Return 25 Oct · evening preferred', + slices: [ + { origin: 'SGN', destination: 'SHA', departure_date: '2026-10-20' }, + { origin: 'BJS', destination: 'SGN', departure_date: '2026-10-25' } + ], + returnWindow: { afterHour: 17 } + }, + { + id: 'return-26', + label: 'Return 26 Oct · morning preferred', + slices: [ + { origin: 'SGN', destination: 'SHA', departure_date: '2026-10-20' }, + { origin: 'BJS', destination: 'SGN', departure_date: '2026-10-26' } + ], + returnWindow: { beforeHour: 12 } + } + ] +}; + +const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Duffel-Version': 'v2', + Authorization: `Bearer ${TOKEN}` +}; + +async function duffel(url, options = {}) { + const response = await fetch(`${API}${url}`, { + ...options, + headers: { ...headers, ...(options.headers || {}) } + }); + + const text = await response.text(); + let body; + try { body = text ? JSON.parse(text) : {}; } + catch { body = { raw: text }; } + + if (!response.ok) { + const message = body?.errors?.map?.(e => e.message).filter(Boolean).join('; ') || body?.message || `HTTP ${response.status}`; + throw new Error(`Duffel request failed: ${message}`); + } + return body; +} + +function passengers() { + return [ + ...Array.from({ length: SEARCH.passengers.adults }, () => ({ type: 'adult' })), + { age: SEARCH.passengers.infantAge } + ]; +} + +function localHour(value) { + if (!value || value.length < 13) return null; + const hour = Number(value.slice(11, 13)); + return Number.isFinite(hour) ? hour : null; +} + +function preferredReturn(offer, scenario) { + const returnSlice = offer?.slices?.[1]; + const depart = returnSlice?.segments?.[0]?.departing_at; + const hour = localHour(depart); + 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 compactSegment(segment) { + return { + origin: segment.origin?.iata_code || segment.origin?.city_name || segment.origin?.name, + destination: segment.destination?.iata_code || segment.destination?.city_name || segment.destination?.name, + departing_at: segment.departing_at, + arriving_at: segment.arriving_at, + duration: segment.duration, + flight_number: segment.marketing_carrier_flight_number || null, + marketing_carrier: segment.marketing_carrier ? { + name: segment.marketing_carrier.name, + iata_code: segment.marketing_carrier.iata_code + } : null, + operating_carrier: segment.operating_carrier ? { + name: segment.operating_carrier.name, + iata_code: segment.operating_carrier.iata_code + } : null + }; +} + +function compactOffer(offer) { + return { + id: offer.id, + live_mode: offer.live_mode, + expires_at: offer.expires_at, + total_amount: offer.total_amount, + total_currency: offer.total_currency, + base_amount: offer.base_amount, + tax_amount: offer.tax_amount, + total_emissions_kg: offer.total_emissions_kg, + owner: offer.owner ? { + name: offer.owner.name, + iata_code: offer.owner.iata_code, + logo_symbol_url: offer.owner.logo_symbol_url || null + } : null, + slices: (offer.slices || []).map(slice => ({ + origin: slice.origin?.iata_code || slice.origin?.city_name || slice.origin?.name, + destination: slice.destination?.iata_code || slice.destination?.city_name || slice.destination?.name, + duration: slice.duration, + segments: (slice.segments || []).map(compactSegment) + })) + }; +} + +async function searchScenario(scenario) { + console.log(`Searching ${scenario.label}...`); + + const created = await duffel('/air/offer_requests?return_offers=false&supplier_timeout=20000', { + method: 'POST', + body: JSON.stringify({ + data: { + slices: scenario.slices, + passengers: passengers(), + cabin_class: SEARCH.cabinClass + } + }) + }); + + const requestId = created?.data?.id; + if (!requestId) throw new Error(`Duffel did not return an offer request id for ${scenario.id}`); + if (created?.data?.live_mode !== true) throw new Error('Duffel token is not in live mode. Refusing to publish test prices as live prices.'); + + const params = new URLSearchParams({ + offer_request_id: requestId, + limit: '100', + sort: 'total_amount', + max_connections: String(SEARCH.maxConnections) + }); + const listed = await duffel(`/air/offers?${params.toString()}`); + const allOffers = listed?.data || []; + const preferred = allOffers.filter(offer => preferredReturn(offer, scenario)); + const selected = (preferred.length ? preferred : allOffers).slice(0, 8).map(compactOffer); + + return { + id: scenario.id, + label: scenario.label, + offer_request_id: requestId, + slices: scenario.slices, + preferred_window_matched: preferred.length > 0, + offers: selected, + offer_count_seen: allOffers.length + }; +} + +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(2); + } +} + +const allCheapest = scenarios + .map(s => ({ scenario_id: s.id, label: s.label, offer: s.offers[0] })) + .filter(x => x.offer); + +const result = { + status: 'ok', + provider: 'Duffel', + generated_at: generatedAt, + live_mode: true, + disclaimer: 'Search snapshot only. Airline offers can change or expire; refresh before booking.', + search: { + passengers: SEARCH.passengers, + cabin_class: SEARCH.cabinClass, + max_connections: SEARCH.maxConnections, + route_label: 'SGN → Shanghai · Beijing → SGN' + }, + scenarios, + cheapest: allCheapest.sort((a, b) => { + if (a.offer.total_currency !== b.offer.total_currency) return 0; + return Number(a.offer.total_amount) - Number(b.offer.total_amount); + })[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 + }] : []; +}); +const history = [...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'}`); +} From 441b8f59c79f72f68edfe83680029516572d9aab Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 06:26:17 +0700 Subject: [PATCH 02/17] Add flight price snapshot placeholder --- data/flights.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 data/flights.json diff --git a/data/flights.json b/data/flights.json new file mode 100644 index 0000000..1273551 --- /dev/null +++ b/data/flights.json @@ -0,0 +1,19 @@ +{ + "status": "setup_required", + "provider": "Duffel", + "generated_at": null, + "live_mode": false, + "disclaimer": "Add the DUFFEL_ACCESS_TOKEN repository secret and run the Update live flight prices workflow to populate real prices.", + "search": { + "passengers": { + "adults": 6, + "infantAge": 1, + "label": "6 adults + 1 infant (<2)" + }, + "cabin_class": "economy", + "max_connections": 1, + "route_label": "SGN → Shanghai · Beijing → SGN" + }, + "scenarios": [], + "cheapest": null +} From 68ead9c44d5378b6aa848b58f627d551f8917a74 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 06:26:23 +0700 Subject: [PATCH 03/17] Add flight price history store --- data/flight-history.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 data/flight-history.json 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 @@ +[] From 34cc6b89a952463009a83061500d4457c7f73b75 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 06:26:38 +0700 Subject: [PATCH 04/17] Add scheduled live flight price workflow --- .github/workflows/update-flight-prices.yml | 77 ++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/update-flight-prices.yml diff --git a/.github/workflows/update-flight-prices.yml b/.github/workflows/update-flight-prices.yml new file mode 100644 index 0000000..2a63c58 --- /dev/null +++ b/.github/workflows/update-flight-prices.yml @@ -0,0 +1,77 @@ +name: Update live flight prices + +on: + workflow_dispatch: + schedule: + # 07:17 and 19:17 in Vietnam (UTC+7) + - cron: '17 0,12 * * *' + +permissions: + contents: write + pages: 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 live Duffel secret + id: config + env: + DUFFEL_ACCESS_TOKEN: ${{ secrets.DUFFEL_ACCESS_TOKEN }} + run: | + if [ -z "$DUFFEL_ACCESS_TOKEN" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning::DUFFEL_ACCESS_TOKEN is not configured. Add a Duffel LIVE token in Settings → Secrets and variables → Actions." + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + fi + + - name: Fetch real airline offers from Duffel + if: steps.config.outputs.enabled == 'true' + env: + DUFFEL_ACCESS_TOKEN: ${{ secrets.DUFFEL_ACCESS_TOKEN }} + run: node scripts/fetch-flights.mjs + + - 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 live flight 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" From 52c83a9577d6d86f49341c216956853b4552bcc2 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 06:27:21 +0700 Subject: [PATCH 05/17] Add live flight price dashboard --- flights.html | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 flights.html diff --git a/flights.html b/flights.html new file mode 100644 index 0000000..1f87929 --- /dev/null +++ b/flights.html @@ -0,0 +1,79 @@ + + + + + + + Live Flight Prices · Travel Log + + + +
+
+
+
Duffel live data

Flight price watch

Real airline offer snapshots for the China trip. The search is run automatically by GitHub Actions and the access token never reaches the browser.

SGN → ShanghaiBeijing → SGN6 adults + 1 infant (<2)EconomyMax 1 stop
+ +
+ +
+
Live snapshots

Current offers

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

Recent range

Based on snapshots saved by the Action
+
checks
lowest
highest
latest airline
+
+ +
Important: these are live-search snapshots, not locked fares. Duffel airline offers can change or expire quickly. Before booking, refresh the workflow and re-check the chosen offer. Prices shown are totals for all 7 travellers in the exact currency returned by the airline/Duffel account.
+
+
© David · Travel Log · Live prices powered by Duffel
+ + + + From b9e2eef9919cdf634f334e960f76ec227a11a9e2 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 06:27:37 +0700 Subject: [PATCH 06/17] Add PR web preview links --- .github/workflows/pr-preview.yml | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/pr-preview.yml diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml new file mode 100644 index 0000000..0524986 --- /dev/null +++ b/.github/workflows/pr-preview.yml @@ -0,0 +1,42 @@ +name: PR Preview + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + preview-link: + runs-on: ubuntu-latest + steps: + - name: Post preview links + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const owner = context.repo.owner; + const repo = context.repo.repo; + const pr = context.payload.pull_request; + const sha = pr.head.sha; + 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, + repo, + issue_number: pr.number, + per_page: 100, + }); + + const existing = comments.find(comment => + comment.user?.type === 'Bot' && comment.body?.includes(marker) + ); + + if (existing) { + 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 }); + } From a8b85d5e70ea81776da2318059e4fa6b8ff355ba Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 06:27:54 +0700 Subject: [PATCH 07/17] Document live flight price tracking --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 446ea54..2798591 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,23 @@ A lightweight personal travel dashboard built for GitHub Pages. It works as a st - Light / dark mode - Mobile bottom navigation - PWA manifest + service worker for install/offline use -- No framework, database or build process required +- **Live airline price snapshots from Duffel via GitHub Actions** +- Price history stored in the repository for later comparison +- No backend server required ## Files ```text -trip/ +trips/ +├── .github/workflows/ +│ ├── pr-preview.yml +│ └── update-flight-prices.yml +├── data/ +│ ├── flights.json +│ └── flight-history.json +├── scripts/ +│ └── fetch-flights.mjs +├── flights.html ├── index.html ├── manifest.webmanifest ├── sw.js @@ -37,10 +48,61 @@ trip/ The starter data is configured for the China trip in October 2026: - Ho Chi Minh City → Shanghai → Beijing -- 20–26 October 2026 -- Day-by-day itinerary included in `index.html` +- Outbound: 20 October 2026 +- Return scenarios: evening 25 October or morning 26 October 2026 +- **6 adults + 1 infant under 2 years old** +- Economy +- Maximum 1 connection per slice -The site intentionally keeps travel data in plain HTML/JavaScript so it is easy to edit directly from GitHub without a build pipeline. +## Live flight prices + +Flight prices are fetched by `.github/workflows/update-flight-prices.yml` using the Duffel **live** API. The Duffel access token is never stored in the repository or sent to the browser. + +### 1. Create a Duffel live access token + +Use the Duffel dashboard to create a live-mode access token. + +### 2. Add the GitHub Actions secret + +Repository → **Settings → Secrets and variables → Actions → New repository secret** + +```text +Name: DUFFEL_ACCESS_TOKEN +Value: duffel_live_... +``` + +Do not add this token to source code, `flights.json`, or any public GitHub variable. + +### 3. Run the first live search + +Repository → **Actions → Update live flight prices → Run workflow** + +The workflow: + +1. searches Duffel for two return-date scenarios, +2. rejects test-mode responses, +3. keeps the cheapest results with at most one connection, +4. stores the current snapshot in `data/flights.json`, +5. appends the cheapest results to `data/flight-history.json`, +6. commits the changed data back to the current branch, +7. explicitly requests a GitHub Pages rebuild when running on `main`. + +### Automatic refresh + +The workflow runs at approximately: + +```text +07:17 Asia/Ho_Chi_Minh +19:17 Asia/Ho_Chi_Minh +``` + +It can also be run manually at any time before checking or booking a fare. + +### Price disclaimer + +`flights.html` shows search snapshots, not locked fares. Airline offers can change or expire quickly, so refresh the workflow before making a booking decision. + +Duffel may charge excess-search fees if the account exceeds its allowed search-to-book ratio. The default schedule in this project intentionally uses only two refreshes per day. ## Personal data @@ -62,6 +124,7 @@ Open: ```text http://localhost:8080 +http://localhost:8080/flights.html ``` Using a local server is recommended when testing the service worker and PWA behavior. @@ -78,6 +141,8 @@ Folder: / (root) The `.nojekyll` file keeps GitHub Pages in simple static-site mode. +The flight refresh workflow uses the GitHub Pages REST endpoint after an automated price commit because commits pushed using the workflow's `GITHUB_TOKEN` do not trigger a Pages build by themselves. + --- Built for personal travel planning and the journeys ahead. From b520f0b5017531efc6afb9b4d7a8dc2b780d8367 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 06:28:07 +0700 Subject: [PATCH 08/17] Cache flight price page for PWA --- sw.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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'))))}); From 50b67983a5592ca022c93a4a6216c7d81dc8b3eb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 06:29:23 +0700 Subject: [PATCH 09/17] Add clean flights route --- flights/index.html | 1 + 1 file changed, 1 insertion(+) create mode 100644 flights/index.html 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

From b5b0df6dc90dc948f977e1ac7deeed8970ca61bd Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 06:45:21 +0700 Subject: [PATCH 10/17] Add configurable GitHub flight price alerts --- .github/workflows/update-flight-prices.yml | 69 ++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/.github/workflows/update-flight-prices.yml b/.github/workflows/update-flight-prices.yml index 2a63c58..8366b54 100644 --- a/.github/workflows/update-flight-prices.yml +++ b/.github/workflows/update-flight-prices.yml @@ -9,6 +9,7 @@ on: permissions: contents: write pages: write + issues: write concurrency: group: live-flight-prices-${{ github.ref }} @@ -47,6 +48,74 @@ jobs: DUFFEL_ACCESS_TOKEN: ${{ secrets.DUFFEL_ACCESS_TOKEN }} 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 Duffel 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 < Flight offers can change or expire. Refresh and verify the fare before booking. + 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 will open a fresh alert." + fi + fi + - name: Commit refreshed price snapshot if: steps.config.outputs.enabled == 'true' id: commit From f245778972e6f7805f52a19950c127956c9b7e24 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 06:46:28 +0700 Subject: [PATCH 11/17] Add flight filters comparisons trends and target alerts --- flights.html | 98 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 70 insertions(+), 28 deletions(-) diff --git a/flights.html b/flights.html index 1f87929..0ebd1d9 100644 --- a/flights.html +++ b/flights.html @@ -6,30 +6,52 @@ Live Flight Prices · Travel Log -
+
-
Duffel live data

Flight price watch

Real airline offer snapshots for the China trip. The search is run automatically by GitHub Actions and the access token never reaches the browser.

SGN → ShanghaiBeijing → SGN6 adults + 1 infant (<2)EconomyMax 1 stop
- +
Duffel live data

Flight price watch

Real airline offer snapshots for the China trip. GitHub Actions refreshes the data automatically while the Duffel access token stays private.

SGN → ShanghaiBeijing → SGN6 adults + 1 infant (<2)EconomyMax 1 stop
+
-
Live snapshots

Current offers

Reading data/flights.json…
+
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 offer in each scenario
+
Waiting for live flight data…
+
+ +
+
Explore offers

Current offers

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

Recent range

Based on snapshots saved by the Action
+
Price tracking

Trend & recent range

One point per saved price check
+
Cheapest saved trendWaiting for history…
checks
lowest
highest
latest airline
-
Important: these are live-search snapshots, not locked fares. Duffel airline offers can change or expire quickly. Before booking, refresh the workflow and re-check the chosen offer. Prices shown are totals for all 7 travellers in the exact currency returned by the airline/Duffel account.
+
Important: these are live-search snapshots, not locked fares. Duffel airline offers can change or expire quickly. Before booking, run a fresh check and verify the chosen offer. The total includes all 7 travellers; the “average per traveller” figure is only a rough division because infant pricing can differ from adult pricing.
© David · Travel Log · Live prices powered by Duffel
@@ -39,13 +61,39 @@ $('#themeBtn').onclick=()=>{const d=document.documentElement.dataset.theme==='dark';document.documentElement.dataset.theme=d?'light':'dark';localStorage.setItem('travel-theme',d?'light':'dark')}; $('#year').textContent=new Date().getFullYear(); - function currency(amount,code){const n=Number(amount);try{return new Intl.NumberFormat('vi-VN',{style:'currency',currency:code,maximumFractionDigits:code==='VND'?0:2}).format(n)}catch{return `${amount} ${code}`}} - function dt(v){if(!v)return '—';const d=new Date(v);return Number.isNaN(d.valueOf())?v:new Intl.DateTimeFormat('en-GB',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}).format(d)} - function duration(v){if(!v)return '';const m=v.match(/PT(?:(\d+)H)?(?:(\d+)M)?/);if(!m)return v;return `${m[1]?m[1]+'h ':''}${m[2]?m[2]+'m':''}`.trim()} - function operators(offer){const names=new Set();(offer.slices||[]).forEach(s=>(s.segments||[]).forEach(seg=>{if(seg.operating_carrier?.name)names.add(seg.operating_carrier.name)}));return [...names].join(', ')||'—'} + let LIVE=null, HISTORY=[]; + const currency=(amount,code)=>{const n=Number(amount);try{return new Intl.NumberFormat('vi-VN',{style:'currency',currency:code,maximumFractionDigits:code==='VND'?0:2}).format(n)}catch{return `${amount} ${code}`}}; + const dt=v=>{if(!v)return '—';const d=new Date(v);return Number.isNaN(d.valueOf())?v:new Intl.DateTimeFormat('en-GB',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}).format(d)}; + const duration=v=>{if(!v)return '';const m=v.match(/PT(?:(\d+)H)?(?:(\d+)M)?/);if(!m)return v;return `${m[1]?m[1]+'h ':''}${m[2]?m[2]+'m':''}`.trim()}; + const durationMin=v=>{if(!v)return 999999;const m=v.match(/PT(?:(\d+)H)?(?:(\d+)M)?/);return m?(Number(m[1]||0)*60+Number(m[2]||0)):999999}; + const operators=o=>{const names=new Set();(o.slices||[]).forEach(s=>(s.segments||[]).forEach(seg=>{if(seg.operating_carrier?.name)names.add(seg.operating_carrier.name)}));return [...names].join(', ')||'—'}; + const stopCount=o=>Math.max(...(o.slices||[]).map(s=>Math.max(0,(s.segments?.length||1)-1)),0); + const totalDuration=o=>(o.slices||[]).reduce((sum,s)=>sum+durationMin(s.duration),0); + const isDirect=o=>(o.slices||[]).every(s=>(s.segments?.length||1)===1); + function legHtml(slice){const first=slice.segments?.[0],last=slice.segments?.at?.(-1)||slice.segments?.[slice.segments.length-1];const stops=Math.max(0,(slice.segments?.length||1)-1);return `
${slice.origin} → ${slice.destination} · ${dt(first?.departing_at)} → ${dt(last?.arriving_at)} · ${duration(slice.duration)}${stops===0?'Direct':stops+' stop'+(stops>1?'s':'')} · Operated by ${slice.segments?.map(s=>s.operating_carrier?.name).filter(Boolean).filter((v,i,a)=>a.indexOf(v)===i).join(', ')||'—'}
`} function deltaHtml(o){if(o.price_delta===undefined)return '';const d=Number(o.price_delta);if(!d)return '
No change
';return `
${d<0?'↓':'↑'} ${currency(Math.abs(d),o.total_currency)} since last check
`} - function offerHtml(o){return `
${o.owner?.logo_symbol_url?``:''}
${o.owner?.name||'Airline'}Operated by ${operators(o)}
${(o.slices||[]).map(legHtml).join('')}
${currency(o.total_amount,o.total_currency)}Total · 7 travellers${deltaHtml(o)}
`} + function offerHtml(o,rank){return `
${o.owner?.logo_symbol_url?``:''}
${o.owner?.name||'Airline'}Operated by ${operators(o)}${rank===0?'Best in this list':''}
${(o.slices||[]).map(legHtml).join('')}
${currency(o.total_amount,o.total_currency)}Total · 7 travellers · ≈ ${currency(Number(o.total_amount)/7,o.total_currency)} avg${deltaHtml(o)}
`} + + function freshness(ts){if(!ts)return {label:'No live check yet',age:null};const age=(Date.now()-new Date(ts).getTime())/3600000;if(age<=14)return {label:'Fresh · '+Math.max(0,Math.floor(age))+'h old',age};if(age<=30)return {label:'Aging · '+Math.floor(age)+'h old',age};return {label:'Stale · '+Math.floor(age)+'h old',age}} + + function populateAirlines(){const names=[...new Set((LIVE.scenarios||[]).flatMap(s=>(s.offers||[]).map(o=>o.owner?.name).filter(Boolean)))].sort();$('#airline').innerHTML=''+names.map(n=>``).join('')} + + function filteredOffers(s){let items=[...(s.offers||[])];const stops=$('#stops').value,airline=$('#airline').value,sort=$('#sort').value;if(stops==='direct')items=items.filter(isDirect);if(stops==='one')items=items.filter(o=>stopCount(o)===1);if(airline!=='all')items=items.filter(o=>o.owner?.name===airline);items.sort((a,b)=>sort==='duration'?totalDuration(a)-totalDuration(b):Number(a.total_amount)-Number(b.total_amount));return items} + + function renderOffers(){if(!LIVE||LIVE.status!=='ok')return;$('#scenarios').innerHTML=(LIVE.scenarios||[]).map(s=>{const items=filteredOffers(s);return `

${s.label}

${s.preferred_window_matched?'Preferred time window matched':'Showing closest available results'} · ${s.offer_count_seen} offers scanned · ${items.length} displayed
${items.length?items.slice(0,8).map((o,i)=>offerHtml(o,i)).join(''):'
No offers match the selected filters.
'}
`}).join('')||'
No flight scenarios available.
'} + + function renderComparison(){const rows=(LIVE.scenarios||[]).map(s=>({s,o:s.offers?.[0]})).filter(x=>x.o);if(!rows.length){$('#comparison').innerHTML='
No comparable live offers.
';return}const same=rows.every(x=>x.o.total_currency===rows[0].o.total_currency);const best=same?[...rows].sort((a,b)=>Number(a.o.total_amount)-Number(b.o.total_amount))[0]:null;$('#comparison').innerHTML=rows.map(({s,o})=>{const delta=best&&s.id!==best.s.id?Number(o.total_amount)-Number(best.o.total_amount):0;return `
${best?.s.id===s.id?'BEST PRICE':''}

${s.label}

${currency(o.total_amount,o.total_currency)}

${o.owner?.name||'Airline'} · ${isDirect(o)?'Direct available':'Up to '+stopCount(o)+' stop'}${delta>0?` · ${currency(delta,o.total_currency)} more than best`:''}

`}).join('')} + + function groupedHistory(currencyCode){const by=new Map();HISTORY.filter(x=>x.total_currency===currencyCode).forEach(x=>{const key=x.checked_at;const n=Number(x.total_amount);if(!Number.isFinite(n))return;const old=by.get(key);if(!old||nnew Date(a.checked_at)-new Date(b.checked_at))} + + function renderSpark(rows,code){const svg=$('#spark');if(rows.length<2){svg.innerHTML='';$('#trendMeta').textContent=rows.length?'Need one more saved check for a trend':'No history yet';return}const recent=rows.slice(-30),vals=recent.map(x=>x.n),min=Math.min(...vals),max=Math.max(...vals),range=max-min||1,pad=12,w=800,h=130;const pts=recent.map((x,i)=>{const px=pad+(i/(recent.length-1))*(w-pad*2);const py=h-pad-((x.n-min)/range)*(h-pad*2);return {x:px,y:py}});svg.innerHTML=``;$('#trendMeta').textContent=`${recent.length} checks · ${currency(min,code)} → ${currency(max,code)}`} + + function renderHistory(cheapest){const code=cheapest?.total_currency;const rows=code?groupedHistory(code):[];const vals=rows.map(x=>x.n);const low=vals.length?Math.min(...vals):null,high=vals.length?Math.max(...vals):null,latest=rows.at(-1);$('#history').innerHTML=`
${rows.length}saved checks
${low!==null?currency(low,code):'—'}lowest saved
${high!==null?currency(high,code):'—'}highest saved
${latest?.airline||'—'}latest cheapest airline
`;renderSpark(rows,code)} + + function renderTarget(cheapest){const input=$('#targetPrice');const saved=localStorage.getItem('flight-target');if(saved&&!input.value)input.value=saved;const banner=$('#targetBanner');banner.classList.remove('show');if(!cheapest||!input.value)return;const target=Number(input.value.replace(/[^0-9.]/g,''));const current=Number(cheapest.total_amount);if(!Number.isFinite(target)||target<=0)return;banner.classList.add('show');if(current<=target){banner.innerHTML=`🎯 Browser target reached. Current cheapest is ${currency(current,cheapest.total_currency)}, which is ${currency(target-current,cheapest.total_currency)} below your target of ${currency(target,cheapest.total_currency)}.`}else{banner.innerHTML=`Price target: ${currency(target,cheapest.total_currency)} · current cheapest is ${currency(current,cheapest.total_currency)} · ${currency(current-target,cheapest.total_currency)} to go.`}} + + function renderSummary(cheapest){const scenarios=(LIVE.scenarios||[]).map(s=>({s,o:s.offers?.[0]})).filter(x=>x.o&&x.o.total_currency===cheapest?.total_currency);const best=scenarios.length?[...scenarios].sort((a,b)=>Number(a.o.total_amount)-Number(b.o.total_amount))[0]:null;const rows=cheapest?groupedHistory(cheapest.total_currency):[],low=rows.length?Math.min(...rows.map(x=>x.n)):null;const d=Number(cheapest?.price_delta);$('#summary').innerHTML=`
${best?.s?.id==='return-25'?'25 Oct':best?.s?.id==='return-26'?'26 Oct':'—'}best return option now
${cheapest?currency(Number(cheapest.total_amount)/7,cheapest.total_currency):'—'}rough avg / traveller
${low!==null?currency(low,cheapest.total_currency):'—'}best saved price
${Number.isFinite(d)?(d===0?'No change':`${d<0?'↓':'↑'} ${currency(Math.abs(d),cheapest.total_currency)}`):'—'}change vs previous
`} async function load(){ try{ @@ -53,26 +101,20 @@ fetch(`./data/flights.json?t=${Date.now()}`,{cache:'no-store'}).then(r=>{if(!r.ok)throw new Error('flights.json unavailable');return r.json()}), fetch(`./data/flight-history.json?t=${Date.now()}`,{cache:'no-store'}).then(r=>r.ok?r.json():[]).catch(()=>[]) ]); + LIVE=data;HISTORY=Array.isArray(history)?history:[]; $('#searchMeta').textContent=`${data.search?.route_label||'Flight search'} · ${data.search?.passengers?.label||''}`; if(data.status!=='ok'){ - $('#cheapest').textContent='Setup needed'; - $('#cheapestMeta').textContent=data.disclaimer||'Run the GitHub Action after adding the live token.'; - $('#scenarios').innerHTML=`
Live token not configured yet.

Add repository secret DUFFEL_ACCESS_TOKEN, then run Update live flight prices from GitHub Actions.
`; - return; + $('#cheapest').textContent='Setup needed';$('#cheapestMeta').textContent=data.disclaimer||'Run the GitHub Action after adding the live token.';$('#freshness').textContent='Waiting for first live check';$('#scenarios').innerHTML=`
Live token not configured yet.

Add repository secret DUFFEL_ACCESS_TOKEN, then run Update live flight prices from GitHub Actions.
`;return; } const cheapest=data.cheapest?.offer; if(cheapest){$('#cheapest').textContent=currency(cheapest.total_amount,cheapest.total_currency);$('#cheapestMeta').textContent=`${data.cheapest.label} · ${cheapest.owner?.name||'Airline'} · total for 7 travellers`} - $('#lastChecked').textContent=`Last checked: ${dt(data.generated_at)}`; - $('#scenarios').innerHTML=(data.scenarios||[]).map(s=>`

${s.label}

${s.preferred_window_matched?'Preferred time window matched':'Showing closest available results'} · ${s.offer_count_seen} offers scanned
${s.offers?.length?s.offers.slice(0,5).map(offerHtml).join(''):'
No offers returned for this scenario.
'}
`).join('')||'
No flight scenarios available.
'; - - const rows=Array.isArray(history)?history:[]; - const comparable=rows.filter(x=>cheapest&&x.total_currency===cheapest.total_currency).map(x=>({...x,n:Number(x.total_amount)})).filter(x=>Number.isFinite(x.n)); - const low=comparable.length?Math.min(...comparable.map(x=>x.n)):null, high=comparable.length?Math.max(...comparable.map(x=>x.n)):null, latest=rows.at?.(-1)||rows[rows.length-1]; - $('#history').innerHTML=`
${rows.length}saved checks
${low!==null?currency(low,cheapest.total_currency):'—'}lowest saved
${high!==null?currency(high,cheapest.total_currency):'—'}highest saved
${latest?.airline||'—'}latest cheapest airline
`; - }catch(e){ - $('#cheapest').textContent='Unavailable';$('#cheapestMeta').textContent=e.message;$('#scenarios').innerHTML=`
Could not load flight price data. ${e.message}
`; - } + $('#lastChecked').textContent=`Last checked: ${dt(data.generated_at)}`;$('#freshness').textContent=freshness(data.generated_at).label; + populateAirlines();renderOffers();renderComparison();renderHistory(cheapest);renderSummary(cheapest);renderTarget(cheapest); + }catch(e){$('#cheapest').textContent='Unavailable';$('#cheapestMeta').textContent=e.message;$('#freshness').textContent='Data unavailable';$('#scenarios').innerHTML=`
Could not load flight price data. ${e.message}
`} } + + ['sort','stops','airline'].forEach(id=>document.getElementById(id).addEventListener('change',renderOffers)); + $('#saveTarget').addEventListener('click',()=>{const v=$('#targetPrice').value.replace(/[^0-9.]/g,'');localStorage.setItem('flight-target',v);$('#targetPrice').value=v;renderTarget(LIVE?.cheapest?.offer)}); load(); From 7ea2f72508d23a0fc5822cfef4f885ed9a119f62 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 06:47:09 +0700 Subject: [PATCH 12/17] Document flight filters trends and price alerts --- README.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2798591..720fb64 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A lightweight personal travel dashboard built for GitHub Pages. It works as a st - Mobile bottom navigation - PWA manifest + service worker for install/offline use - **Live airline price snapshots from Duffel via GitHub Actions** -- Price history stored in the repository for later comparison +- Price comparison, filters, target alerts and saved price history - No backend server required ## Files @@ -58,6 +58,25 @@ The starter data is configured for the China trip in October 2026: Flight prices are fetched by `.github/workflows/update-flight-prices.yml` using the Duffel **live** API. The Duffel access token is never stored in the repository or sent to the browser. +### Flight dashboard features + +`flights.html` includes: + +- direct comparison of returning on 25 vs 26 October +- Cheapest / Fastest sorting +- Direct only / 1 stop filtering +- airline filtering +- current cheapest total for all 7 travellers +- rough average price per traveller +- difference from the previous check +- lowest and highest saved prices +- a price-history trend chart +- live-data freshness indicator +- browser-local target price +- shortcut to manually trigger a fresh GitHub Actions check + +The browser target is stored only on that device. For automatic notifications, configure the GitHub Issue alert below. + ### 1. Create a Duffel live access token Use the Duffel dashboard to create a live-mode access token. @@ -73,7 +92,32 @@ Value: duffel_live_... Do not add this token to source code, `flights.json`, or any public GitHub variable. -### 3. Run the first live search +### 3. Optional: automatic GitHub price alert + +The workflow can open a GitHub Issue when the cheapest total reaches a target. This uses normal GitHub notifications and does not require another service. + +Repository → **Settings → Secrets and variables → Actions → Variables** + +Example: + +```text +FLIGHT_ALERT_AMOUNT=30000000 +FLIGHT_ALERT_CURRENCY=VND +``` + +`FLIGHT_ALERT_AMOUNT` enables the alert. `FLIGHT_ALERT_CURRENCY` is optional; if omitted, the workflow uses the currency returned by the cheapest current offer. + +If the configured currency differs from the Duffel result, the workflow skips the comparison rather than performing an implicit currency conversion. + +When the live total is at or below the target, the workflow opens or updates: + +```text +✈️ Flight price alert · China 2026 +``` + +When the fare rises above the target again, the alert issue is closed. A later drop can create a fresh notification. + +### 4. Run the first live search Repository → **Actions → Update live flight prices → Run workflow** @@ -84,8 +128,9 @@ The workflow: 3. keeps the cheapest results with at most one connection, 4. stores the current snapshot in `data/flights.json`, 5. appends the cheapest results to `data/flight-history.json`, -6. commits the changed data back to the current branch, -7. explicitly requests a GitHub Pages rebuild when running on `main`. +6. checks the optional GitHub Issue price target, +7. commits the changed data back to the current branch, +8. explicitly requests a GitHub Pages rebuild when running on `main`. ### Automatic refresh @@ -102,7 +147,7 @@ It can also be run manually at any time before checking or booking a fare. `flights.html` shows search snapshots, not locked fares. Airline offers can change or expire quickly, so refresh the workflow before making a booking decision. -Duffel may charge excess-search fees if the account exceeds its allowed search-to-book ratio. The default schedule in this project intentionally uses only two refreshes per day. +The “average per traveller” display is only a simple total ÷ 7 reference value. Infant pricing may differ significantly from adult pricing. ## Personal data From e8902622133261bc5d1f6d1010982e6a43d4e037 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 22:07:34 +0700 Subject: [PATCH 13/17] Replace Duffel fetcher with SerpApi Google Flights --- scripts/fetch-flights.mjs | 343 ++++++++++++++++++++++++++------------ 1 file changed, 232 insertions(+), 111 deletions(-) diff --git a/scripts/fetch-flights.mjs b/scripts/fetch-flights.mjs index ace5f5d..18a29b8 100644 --- a/scripts/fetch-flights.mjs +++ b/scripts/fetch-flights.mjs @@ -1,174 +1,288 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -const TOKEN = process.env.DUFFEL_ACCESS_TOKEN; -if (!TOKEN) { - console.error('Missing DUFFEL_ACCESS_TOKEN. Add it as a GitHub Actions repository secret.'); +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://api.duffel.com'; +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, - infantAge: 1, - label: '6 adults + 1 infant (<2)' + 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', - slices: [ - { origin: 'SGN', destination: 'SHA', departure_date: '2026-10-20' }, - { origin: 'BJS', destination: 'SGN', departure_date: '2026-10-25' } + 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', - slices: [ - { origin: 'SGN', destination: 'SHA', departure_date: '2026-10-20' }, - { origin: 'BJS', destination: 'SGN', departure_date: '2026-10-26' } + 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 } } ] }; -const headers = { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'Duffel-Version': 'v2', - Authorization: `Bearer ${TOKEN}` -}; - -async function duffel(url, options = {}) { - const response = await fetch(`${API}${url}`, { - ...options, - headers: { ...headers, ...(options.headers || {}) } +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) { - const message = body?.errors?.map?.(e => e.message).filter(Boolean).join('; ') || body?.message || `HTTP ${response.status}`; - throw new Error(`Duffel request failed: ${message}`); + 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 passengers() { +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.from({ length: SEARCH.passengers.adults }, () => ({ type: 'adult' })), - { age: SEARCH.passengers.infantAge } - ]; + ...(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 || value.length < 13) return null; - const hour = Number(value.slice(11, 13)); + 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 preferredReturn(offer, scenario) { - const returnSlice = offer?.slices?.[1]; - const depart = returnSlice?.segments?.[0]?.departing_at; - const hour = localHour(depart); - 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 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 compactSegment(segment) { +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 { - origin: segment.origin?.iata_code || segment.origin?.city_name || segment.origin?.name, - destination: segment.destination?.iata_code || segment.destination?.city_name || segment.destination?.name, - departing_at: segment.departing_at, - arriving_at: segment.arriving_at, - duration: segment.duration, - flight_number: segment.marketing_carrier_flight_number || null, - marketing_carrier: segment.marketing_carrier ? { - name: segment.marketing_carrier.name, - iata_code: segment.marketing_carrier.iata_code - } : null, - operating_carrier: segment.operating_carrier ? { - name: segment.operating_carrier.name, - iata_code: segment.operating_carrier.iata_code - } : null + 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(offer) { +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: offer.id, - live_mode: offer.live_mode, - expires_at: offer.expires_at, - total_amount: offer.total_amount, - total_currency: offer.total_currency, - base_amount: offer.base_amount, - tax_amount: offer.tax_amount, - total_emissions_kg: offer.total_emissions_kg, - owner: offer.owner ? { - name: offer.owner.name, - iata_code: offer.owner.iata_code, - logo_symbol_url: offer.owner.logo_symbol_url || null - } : null, - slices: (offer.slices || []).map(slice => ({ - origin: slice.origin?.iata_code || slice.origin?.city_name || slice.origin?.name, - destination: slice.destination?.iata_code || slice.destination?.city_name || slice.destination?.name, - duration: slice.duration, - segments: (slice.segments || []).map(compactSegment) - })) + 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}...`); - const created = await duffel('/air/offer_requests?return_offers=false&supplier_timeout=20000', { - method: 'POST', - body: JSON.stringify({ - data: { - slices: scenario.slices, - passengers: passengers(), - cabin_class: SEARCH.cabinClass - } - }) - }); + // 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 requestId = created?.data?.id; - if (!requestId) throw new Error(`Duffel did not return an offer request id for ${scenario.id}`); - if (created?.data?.live_mode !== true) throw new Error('Duffel token is not in live mode. Refusing to publish test prices as live prices.'); + 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 params = new URLSearchParams({ - offer_request_id: requestId, - limit: '100', - sort: 'total_amount', - max_connections: String(SEARCH.maxConnections) + const next = await serpapi({ + ...baseParams(scenario), + departure_token: outbound.departure_token }); - const listed = await duffel(`/air/offers?${params.toString()}`); - const allOffers = listed?.data || []; - const preferred = allOffers.filter(offer => preferredReturn(offer, scenario)); - const selected = (preferred.length ? preferred : allOffers).slice(0, 8).map(compactOffer); + + 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, - offer_request_id: requestId, - slices: scenario.slices, + 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: allOffers.length + offer_count_seen: normalized.length, + price_insights: next?.price_insights || null }; } @@ -190,33 +304,37 @@ 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(2); + 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); + .filter(x => x.offer) + .sort((a, b) => Number(a.offer.total_amount) - Number(b.offer.total_amount)); const result = { - status: 'ok', - provider: 'Duffel', + status: allCheapest.length ? 'ok' : 'no_results', + provider: 'SerpApi', + source: 'Google Flights', generated_at: generatedAt, live_mode: true, - disclaimer: 'Search snapshot only. Airline offers can change or expire; refresh before booking.', + 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.sort((a, b) => { - if (a.offer.total_currency !== b.offer.total_currency) return 0; - return Number(a.offer.total_amount) - Number(b.offer.total_amount); - })[0] || null + cheapest: allCheapest[0] || null }; const historyRows = scenarios.flatMap(s => { @@ -226,10 +344,13 @@ const historyRows = scenarios.flatMap(s => { scenario_id: s.id, total_amount: o.total_amount, total_currency: o.total_currency, - airline: o.owner?.name || null + airline: o.owner?.name || null, + provider: 'SerpApi', + source: 'Google Flights' }] : []; }); -const history = [...oldHistory, ...historyRows].slice(-360); + +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'); From 5b3c8265b4adae31fcd2d83f4124d503431302bb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 22:07:58 +0700 Subject: [PATCH 14/17] Use SerpApi in flight price workflow --- .github/workflows/update-flight-prices.yml | 29 +++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/.github/workflows/update-flight-prices.yml b/.github/workflows/update-flight-prices.yml index 8366b54..7bfc654 100644 --- a/.github/workflows/update-flight-prices.yml +++ b/.github/workflows/update-flight-prices.yml @@ -3,8 +3,9 @@ name: Update live flight prices on: workflow_dispatch: schedule: - # 07:17 and 19:17 in Vietnam (UTC+7) - - cron: '17 0,12 * * *' + # 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 @@ -30,22 +31,22 @@ jobs: with: node-version: '20' - - name: Check live Duffel secret + - name: Check SerpApi secret id: config env: - DUFFEL_ACCESS_TOKEN: ${{ secrets.DUFFEL_ACCESS_TOKEN }} + SERPAPI_API_KEY: ${{ secrets.SERPAPI_API_KEY }} run: | - if [ -z "$DUFFEL_ACCESS_TOKEN" ]; then + if [ -z "$SERPAPI_API_KEY" ]; then echo "enabled=false" >> "$GITHUB_OUTPUT" - echo "::warning::DUFFEL_ACCESS_TOKEN is not configured. Add a Duffel LIVE token in Settings → Secrets and variables → Actions." + 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 real airline offers from Duffel + - name: Fetch Google Flights prices via SerpApi if: steps.config.outputs.enabled == 'true' env: - DUFFEL_ACCESS_TOKEN: ${{ secrets.DUFFEL_ACCESS_TOKEN }} + SERPAPI_API_KEY: ${{ secrets.SERPAPI_API_KEY }} run: node scripts/fetch-flights.mjs - name: Evaluate price alert @@ -74,7 +75,7 @@ jobs: fi if [ "$CURRENT_CURRENCY" != "$ALERT_CURRENCY" ]; then - echo "::warning::Price alert currency is $ALERT_CURRENCY but Duffel returned $CURRENT_CURRENCY. Alert comparison skipped." + echo "::warning::Price alert currency is $ALERT_CURRENCY but Google Flights returned $CURRENT_CURRENCY. Alert comparison skipped." exit 0 fi @@ -85,7 +86,7 @@ jobs: BODY=$(cat < Flight offers can change or expire. Refresh and verify the fare before booking. + > Search prices can change. Verify the itinerary and final booking price before paying. EOF ) @@ -112,7 +113,7 @@ jobs: 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 will open a fresh alert." + 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 @@ -129,7 +130,7 @@ jobs: 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 live flight prices [skip ci]" + git commit -m "chore: refresh Google Flights prices [skip ci]" git push origin "HEAD:${GITHUB_REF_NAME}" echo "changed=true" >> "$GITHUB_OUTPUT" From 44e16b6822c3b3c7a586228b2f08e9806c82aa71 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 22:08:08 +0700 Subject: [PATCH 15/17] Switch initial flight data to SerpApi --- data/flights.json | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/data/flights.json b/data/flights.json index 1273551..eb16e3d 100644 --- a/data/flights.json +++ b/data/flights.json @@ -1,17 +1,20 @@ { "status": "setup_required", - "provider": "Duffel", + "provider": "SerpApi", + "source": "Google Flights", "generated_at": null, "live_mode": false, - "disclaimer": "Add the DUFFEL_ACCESS_TOKEN repository secret and run the Update live flight prices workflow to populate real prices.", + "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, - "infantAge": 1, - "label": "6 adults + 1 infant (<2)" + "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": [], From 9a57bfa16913331c46a5bc48ff28e7505b6e37ec Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 22:08:26 +0700 Subject: [PATCH 16/17] Document SerpApi Google Flights integration --- README.md | 200 ++++++++++++++++++++++++------------------------------ 1 file changed, 87 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 720fb64..ec8985a 100644 --- a/README.md +++ b/README.md @@ -1,165 +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 -- **Live airline price snapshots from Duffel via GitHub Actions** -- Price comparison, filters, target alerts and saved price history -- No backend server 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 - -```text -trips/ -├── .github/workflows/ -│ ├── pr-preview.yml -│ └── update-flight-prices.yml -├── data/ -│ ├── flights.json -│ └── flight-history.json -├── scripts/ -│ └── fetch-flights.mjs -├── flights.html -├── index.html -├── manifest.webmanifest -├── sw.js -├── icon.svg -├── .nojekyll -└── README.md -``` - -## Current trip - -The starter data is configured for the China trip in October 2026: +## Current China trip - Ho Chi Minh City → Shanghai → Beijing -- Outbound: 20 October 2026 -- Return scenarios: evening 25 October or morning 26 October 2026 -- **6 adults + 1 infant under 2 years old** +- Outbound: **20 October 2026** +- Return options: **25 October evening** or **26 October morning** +- **6 adults + 1 infant under 2 on lap** - Economy -- Maximum 1 connection per slice +- Direct or maximum 1 stop per leg ## Live flight prices -Flight prices are fetched by `.github/workflows/update-flight-prices.yml` using the Duffel **live** API. The Duffel access token is never stored in the repository or sent to the browser. +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. -### Flight dashboard features +Google Flights multi-city selection is sequential. For each return-date scenario the fetcher performs: -`flights.html` includes: - -- direct comparison of returning on 25 vs 26 October -- Cheapest / Fastest sorting -- Direct only / 1 stop filtering -- airline filtering -- current cheapest total for all 7 travellers -- rough average price per traveller -- difference from the previous check -- lowest and highest saved prices -- a price-history trend chart -- live-data freshness indicator -- browser-local target price -- shortcut to manually trigger a fresh GitHub Actions check +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. -The browser target is stored only on that device. For automatic notifications, configure the GitHub Issue alert below. +There are two scenarios, so one refresh uses **4 SerpApi searches**. -### 1. Create a Duffel live access token +### 1. Create a SerpApi key -Use the Duffel dashboard to create a live-mode access token. +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 -Name: DUFFEL_ACCESS_TOKEN -Value: duffel_live_... +Name: SERPAPI_API_KEY +Value: ``` -Do not add this token to source code, `flights.json`, or any public GitHub variable. +Do not add the key to source code, repository variables, `flights.json`, issues or PR comments. -### 3. Optional: automatic GitHub price alert +### 3. Merge the PR and run the first check -The workflow can open a GitHub Issue when the cheapest total reaches a target. This uses normal GitHub notifications and does not require another service. +After the workflow exists on `main`: -Repository → **Settings → Secrets and variables → Actions → Variables** +```text +Actions +→ Update live flight prices +→ Run workflow +``` -Example: +The workflow writes: ```text -FLIGHT_ALERT_AMOUNT=30000000 -FLIGHT_ALERT_CURRENCY=VND +data/flights.json +data/flight-history.json ``` -`FLIGHT_ALERT_AMOUNT` enables the alert. `FLIGHT_ALERT_CURRENCY` is optional; if omitted, the workflow uses the currency returned by the cheapest current offer. +and commits refreshed snapshots back to `main`. -If the configured currency differs from the Duffel result, the workflow skips the comparison rather than performing an implicit currency conversion. +### Automatic refresh -When the live total is at or below the target, the workflow opens or updates: +The default schedule is: ```text -✈️ Flight price alert · China 2026 +07:17 Asia/Ho_Chi_Minh ``` -When the fare rises above the target again, the alert issue is closed. A later drop can create a fresh notification. +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. -### 4. Run the first live search +### Price dashboard -Repository → **Actions → Update live flight prices → Run workflow** +`flights.html` provides: -The workflow: - -1. searches Duffel for two return-date scenarios, -2. rejects test-mode responses, -3. keeps the cheapest results with at most one connection, -4. stores the current snapshot in `data/flights.json`, -5. appends the cheapest results to `data/flight-history.json`, -6. checks the optional GitHub Issue price target, -7. commits the changed data back to the current branch, -8. explicitly requests a GitHub Pages rebuild when running on `main`. +- 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 -### Automatic refresh +## Optional GitHub Issue price alert -The workflow runs at approximately: +Create repository Actions variables: ```text -07:17 Asia/Ho_Chi_Minh -19:17 Asia/Ho_Chi_Minh +FLIGHT_ALERT_AMOUNT=30000000 +FLIGHT_ALERT_CURRENCY=VND ``` -It can also be run manually at any time before checking or booking a fare. +When the current cheapest total is at or below the threshold, the workflow opens or updates: -### Price disclaimer +```text +✈️ Flight price alert · China 2026 +``` -`flights.html` shows search snapshots, not locked fares. Airline offers can change or expire quickly, so refresh the workflow before making a booking decision. +When the price moves above the target again, the issue is closed. -The “average per traveller” display is only a simple total ÷ 7 reference value. Infant pricing may differ significantly from adult pricing. +## Price notes -## Personal data +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. -Checklist, budget and notes are stored in the browser using `localStorage` under: +## Files ```text -travel-log-v2 +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 ``` -Use **Export data** before changing browsers/devices. The exported JSON file can later be restored with **Import data**. - -## Run locally +## Local development ```bash python3 -m http.server 8080 @@ -168,25 +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 flight refresh workflow uses the GitHub Pages REST endpoint after an automated price commit because commits pushed using the workflow's `GITHUB_TOKEN` do not trigger a Pages build by themselves. +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. --- From c35b5180f550d8d06cc544e91fc40006eb40bc05 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 20 Aug 2026 22:09:48 +0700 Subject: [PATCH 17/17] Update flight dashboard for Google Flights via SerpApi --- flights.html | 146 ++++++++++++++------------------------------------- 1 file changed, 39 insertions(+), 107 deletions(-) diff --git a/flights.html b/flights.html index 0ebd1d9..e164025 100644 --- a/flights.html +++ b/flights.html @@ -1,121 +1,53 @@ - - - - Live Flight Prices · Travel Log + + + + + Flight Price Watch · Travel Log -
-
-
-
Duffel live data

Flight price watch

Real airline offer snapshots for the China trip. GitHub Actions refreshes the data automatically while the Duffel access token stays private.

SGN → ShanghaiBeijing → SGN6 adults + 1 infant (<2)EconomyMax 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 offer in each scenario
-
Waiting for live flight data…
-
- -
-
Explore offers

Current offers

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

Trend & recent range

One point per saved price check
-
Cheapest saved trendWaiting for history…
-
checks
lowest
highest
latest airline
-
- -
Important: these are live-search snapshots, not locked fares. Duffel airline offers can change or expire quickly. Before booking, run a fresh check and verify the chosen offer. The total includes all 7 travellers; the “average per traveller” figure is only a rough division because infant pricing can differ from adult pricing.
-
-
© David · Travel Log · Live prices powered by Duffel
- +
+
+
+
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