Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a17d3fb
chore: apply hotel navigation and workflow race fix
hoangsvit Aug 27, 2026
8e40126
fix: escape workflow expressions in patch runner
hoangsvit Aug 27, 2026
78f9a5a
fix: limit patch runner to navigation files
hoangsvit Aug 27, 2026
8819efe
fix: expose hotel price tracker navigation
github-actions[bot] Aug 27, 2026
aba9b8b
fix: serialize flight and hotel snapshot writers
hoangsvit Aug 27, 2026
1b8688b
fix: serialize hotel and flight snapshot writers
hoangsvit Aug 27, 2026
7b4f649
chore: remove one-time navigation patch workflow
hoangsvit Aug 27, 2026
3a93f1c
fix: keep hotel results when rates are unavailable
hoangsvit Aug 27, 2026
7e97a46
fix: render hotels without live prices
hoangsvit Aug 27, 2026
ae04951
test: seed tracked hotels when live rates are unavailable
hoangsvit Aug 27, 2026
fc62b93
fix: discover Shanghai hotels before filtering
hoangsvit Aug 27, 2026
f45c8d7
feat: add full hotel discovery filters
hoangsvit Aug 27, 2026
0e28246
style: add hotel discovery filters
hoangsvit Aug 27, 2026
a3c8b88
feat: add client-side hotel filtering and favorites
hoangsvit Aug 27, 2026
7680490
fix: harden Shanghai hotel discovery
hoangsvit Aug 27, 2026
dfd6094
fix: validate hotel refreshes and control discovery depth
hoangsvit Aug 27, 2026
d738bb1
chore: remove legacy hotel fallback snapshot
hoangsvit Aug 27, 2026
5559a15
fix: bootstrap hotel catalog with deeper discovery
hoangsvit Aug 27, 2026
72bca6f
chore: keep preview snapshot explicit
hoangsvit Aug 27, 2026
f927154
fix: show explicit hotel snapshot state
hoangsvit Aug 27, 2026
b2af2ba
fix: expose hotel data status in preview
hoangsvit Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/update-flight-prices.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ permissions:
issues: write

concurrency:
group: live-flight-prices-${{ github.ref }}
group: price-tracker-writes-${{ github.ref }}
cancel-in-progress: false

jobs:
Expand Down
68 changes: 60 additions & 8 deletions .github/workflows/update-hotel-prices.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ name: Update live hotel prices

on:
workflow_dispatch:
inputs:
max_pages:
description: 'Discovery depth (manual runs only)'
required: false
default: '5'
type: choice
options:
- '3'
- '5'
- '8'
push:
branches:
- main
Expand All @@ -18,7 +28,7 @@ permissions:
issues: write

concurrency:
group: live-hotel-prices-${{ github.ref }}
group: price-tracker-writes-${{ github.ref }}
cancel-in-progress: false

jobs:
Expand Down Expand Up @@ -52,6 +62,26 @@ jobs:
fi
echo "enabled=true" >> "$GITHUB_OUTPUT"

- name: Resolve discovery depth
id: depth
shell: bash
env:
MANUAL_MAX_PAGES: ${{ inputs.max_pages }}
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
PAGES="${MANUAL_MAX_PAGES:-5}"
MODE="manual-deep-discovery"
elif [ "${GITHUB_EVENT_NAME}" = "push" ]; then
PAGES="5"
MODE="bootstrap-deep-discovery"
else
PAGES="3"
MODE="daily-light-refresh"
fi
echo "max_pages=$PAGES" >> "$GITHUB_OUTPUT"
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
echo "Hotel search mode: $MODE · max pages: $PAGES"

- name: Fetch Google Hotels prices via SerpApi
id: search
if: steps.config.outputs.enabled == 'true'
Expand All @@ -60,6 +90,7 @@ jobs:
SERPAPI_API_KEY_2: ${{ secrets.SERPAPI_API_KEY_2 }}
SERPAPI_API_KEY_3: ${{ secrets.SERPAPI_API_KEY_3 }}
SERPAPI_BOOKING_API_KEY: ${{ secrets.SERPAPI_BOOKING_API_KEY }}
HOTEL_MAX_PAGES: ${{ steps.depth.outputs.max_pages }}
shell: bash
run: |
set -euo pipefail
Expand All @@ -75,7 +106,7 @@ jobs:

echo "Trying SerpApi credential ${labels[$index]} for Google Hotels..."
set +e
output=$(SERPAPI_API_KEY="$key" node scripts/fetch-hotels.mjs 2>&1)
output=$(SERPAPI_API_KEY="$key" HOTEL_MAX_PAGES="$HOTEL_MAX_PAGES" node scripts/fetch-hotels.mjs 2>&1)
status=$?
set -e
printf '%s\n' "$output"
Expand All @@ -87,6 +118,11 @@ jobs:
break
fi

if printf '%s' "$output" | grep -q 'ZERO_RESULTS:'; then
echo "::error::Google Hotels returned zero properties; keeping the previous good snapshot unchanged."
exit "$status"
fi

if printf '%s' "$output" | grep -Eqi '(^|[^0-9])429([^0-9]|$)|quota|rate[ -]?limit|monthly[[:space:]]+search|search(es)?[[:space:]]+limit|credit(s)?[[:space:]]+(exhausted|limit)'; then
echo "::warning::Credential ${labels[$index]} has no usable quota/rate capacity. Trying the next credential."
else
Expand All @@ -102,6 +138,19 @@ jobs:
test -f data/hotels.json || { echo "::error::Missing data/hotels.json after search."; exit 3; }
test -f data/hotel-history.json || { echo "::error::Missing data/hotel-history.json after search."; exit 3; }

node - <<'NODE'
const data = require('./data/hotels.json');
const raw = Number(data?.search?.raw_property_count || 0);
const unique = Number(data?.search?.latest_unique_property_count || 0);
const priced = Number(data?.search?.priced_property_count || 0);
const pages = Number(data?.search?.pages_fetched || 0);
if (!raw || !unique || !pages) {
console.error('Invalid hotel snapshot after fetch:', { raw, unique, priced, pages });
process.exit(4);
}
console.log(`Validated hotel snapshot: ${pages} page(s), ${raw} raw, ${unique} latest unique, ${priced} priced.`);
NODE

- name: Evaluate hotel price alert
if: steps.config.outputs.enabled == 'true' && github.ref_name == 'main' && steps.search.outcome == 'success'
env:
Expand All @@ -115,8 +164,8 @@ jobs:
exit 0
fi

CURRENT_AMOUNT=$(node -e "const d=require('./data/hotels.json');const id=d.cheapest_shortlisted_property_id||d.cheapest_property_id;const p=(d.properties||[]).find(x=>x.id===id);process.stdout.write(p?.rate_per_night?.amount?String(p.rate_per_night.amount):'')")
CURRENT_HOTEL=$(node -e "const d=require('./data/hotels.json');const id=d.cheapest_shortlisted_property_id||d.cheapest_property_id;const p=(d.properties||[]).find(x=>x.id===id);process.stdout.write(p?.name||'Unknown hotel')")
CURRENT_AMOUNT=$(node -e "const d=require('./data/hotels.json');const id=d.cheapest_property_id;const p=(d.properties||[]).find(x=>x.id===id);process.stdout.write(p?.rate_per_night?.amount?String(p.rate_per_night.amount):'')")
CURRENT_HOTEL=$(node -e "const d=require('./data/hotels.json');const id=d.cheapest_property_id;const p=(d.properties||[]).find(x=>x.id===id);process.stdout.write(p?.name||'Unknown hotel')")
CHECKED_AT=$(node -p "require('./data/hotels.json').generated_at || new Date().toISOString()")
CURRENT_CURRENCY=$(node -p "require('./data/hotels.json').search?.currency || 'VND'")
ALERT_CURRENCY=${ALERT_CURRENCY:-$CURRENT_CURRENCY}
Expand All @@ -134,7 +183,7 @@ jobs:
ISSUE=$(gh issue list --state open --json number,title --jq '.[] | select(.title == "🏨 Hotel price alert · Shanghai 19–20 Oct 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 <<EOF
BODY=$(cat <<EOF2
## Hotel target reached 🎯

| | |
Expand All @@ -149,7 +198,7 @@ jobs:
Dashboard: https://trips.eplus.dev/hotels.html

> Google Hotels is a price snapshot. Verify taxes, fees, cancellation policy, room type and final checkout total before paying.
EOF
EOF2
)
if [ -n "$ISSUE" ]; then
gh issue edit "$ISSUE" --body "$BODY"
Expand All @@ -175,7 +224,7 @@ jobs:
run: node scripts/update-api-usage.mjs

- name: Commit refreshed snapshots
if: always() && steps.config.outputs.enabled == 'true'
if: steps.search.outcome == 'success'
id: commit
shell: bash
run: |
Expand All @@ -189,11 +238,14 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add data/hotels.json data/hotel-history.json data/api-usage.json README.md
git commit -m "chore: refresh Google Hotels and API usage [skip ci]"

git fetch origin "${GITHUB_REF_NAME}"
git rebase "origin/${GITHUB_REF_NAME}"
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'
if: steps.commit.outputs.changed == 'true' && github.ref_name == 'main'
env:
GH_TOKEN: ${{ github.token }}
run: |
Expand Down
32 changes: 23 additions & 9 deletions data/hotels.json
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
{
"status": "no_results",
"status": "pending_refresh",
"snapshot_state": "preview_pending_refresh",
"snapshot_schema": 2,
"provider": "SerpApi",
"source": "Google Hotels",
"generated_at": "2026-08-27T09:28:42.009Z",
"live_mode": true,
"disclaimer": "Rates are Google Hotels snapshots for one room with 2 adults. Taxes, fees, room type and final checkout price can differ. The 3-room figure is only a simple estimate for the 6-adult group and does not confirm availability of three identical rooms.",
"generated_at": null,
"live_mode": false,
"disclaimer": "Preview placeholder only. No hard-coded hotel fallback is shown. Run the live hotel workflow after merge to populate Google Hotels discovery data.",
"search": {
"trip_key": "shanghai-jinling-2026-10-19__2026-10-20",
"query": "Jinling East Road Shanghai hotels",
"location_label": "Jinling East Road · Dashijie · The Bund · Yu Garden, Shanghai",
"trip_key": "shanghai-2026-10-19__2026-10-20",
"query": "Shanghai hotels",
"location_label": "Shanghai · lọc theo khoảng cách từ 531 Jinling East Road",
"check_in_date": "2026-10-19",
"check_out_date": "2026-10-20",
"nights": 1,
"adults_per_room": 2,
"children": 0,
"currency": "VND",
"group_rooms_estimate": 3,
"searches_per_refresh": 1,
"google_hotels_url": "https://www.google.com/travel/search?q=Jinling+East+Road+Shanghai+hotels&hl=en&gl=vn"
"anchor": {
"name": "531 Jinling East Road",
"latitude": 31.2285,
"longitude": 121.4808
},
"max_pages": 5,
"pages_fetched": 0,
"searches_per_refresh": 0,
"google_hotels_url": null,
"raw_property_count": 0,
"latest_unique_property_count": 0,
"preserved_catalog_count": 0,
"displayed_property_count": 0,
"priced_property_count": 0
},
"properties": [],
"cheapest_property_id": null,
Expand Down
5 changes: 3 additions & 2 deletions flights.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@
<a href="./index.html"><span class="nav-icon"><svg><use href="#i-home"/></svg></span>Tổng quan</a>
<a href="./index.html#itinerary"><span class="nav-icon"><svg><use href="#i-calendar"/></svg></span>Lịch trình</a>
<a class="active" href="./flights.html"><span class="nav-icon"><svg><use href="#i-plane"/></svg></span>Giá vé</a>
<a href="./hotels.html"><span class="nav-icon"><svg><use href="#i-pin"/></svg></span>Giá phòng</a>
<a href="./index.html#budget"><span class="nav-icon"><svg><use href="#i-wallet"/></svg></span>Ngân sách</a>
<a href="./index.html#checklist"><span class="nav-icon"><svg><use href="#i-check"/></svg></span>Chuẩn bị</a>
<a href="./index.html#notes"><span class="nav-icon"><svg><use href="#i-note"/></svg></span>Ghi chú</a>
</nav>
<div class="sidebar-foot"><button class="sidebar-action" data-theme-toggle><span class="nav-icon"><svg><use href="#i-moon"/></svg></span>Đổi giao diện</button></div>
</aside>
<div class="main">
<header class="topbar"><div class="crumb"><strong>Giá vé</strong><span>Hành trình Trung Quốc 2026</span></div><div class="top-actions"><a class="btn sm" href="./index.html">Về tổng quan</a><button class="icon-btn" data-theme-toggle aria-label="Chuyển sang giao diện tối"><svg class="ui-icon"><use href="#i-moon"/></svg></button></div></header>
<header class="topbar"><div class="crumb"><strong>Giá vé</strong><span>Hành trình Trung Quốc 2026</span></div><div class="top-actions"><a class="btn sm" href="./hotels.html">Giá phòng</a><a class="btn sm" href="./index.html">Về tổng quan</a><button class="icon-btn" data-theme-toggle aria-label="Chuyển sang giao diện tối"><svg class="ui-icon"><use href="#i-moon"/></svg></button></div></header>
<header class="mobile-header"><a class="brand" href="./index.html"><span class="brand-mark"><svg><use href="#i-plane"/></svg></span>TravelLog</a><div class="right"><button class="icon-btn" data-theme-toggle aria-label="Chuyển sang giao diện tối"><svg class="ui-icon"><use href="#i-moon"/></svg></button></div></header>
<main class="content booking-content">
<section class="booking-heading">
Expand Down Expand Up @@ -59,7 +60,7 @@
</main>
</div>
</div>
<nav class="mobile-bottom"><a href="./index.html"><svg><use href="#i-home"/></svg>Tổng quan</a><a href="./index.html#itinerary"><svg><use href="#i-calendar"/></svg>Lịch trình</a><a class="active" href="./flights.html"><svg><use href="#i-plane"/></svg>Giá vé</a><a href="./index.html#checklist"><svg><use href="#i-check"/></svg>Chuẩn bị</a></nav>
<nav class="mobile-bottom"><a href="./index.html"><svg><use href="#i-home"/></svg>Tổng quan</a><a href="./index.html#itinerary"><svg><use href="#i-calendar"/></svg>Lịch trình</a><a class="active" href="./flights.html"><svg><use href="#i-plane"/></svg>Giá vé</a><a href="./hotels.html"><svg><use href="#i-pin"/></svg>Giá phòng</a></nav>
<svg width="0" height="0" style="position:absolute;overflow:hidden" aria-hidden="true">
<symbol id="i-plane" viewBox="0 0 24 24"><path d="M10.5 4h3l2.2 6H20a2 2 0 1 1 0 4h-4.3l-2.2 6h-3l1.2-6H7l-2 2H3l1.2-4L3 8h2l2 2h4.7l-1.2-6Z"/></symbol><symbol id="i-home" viewBox="0 0 24 24"><path d="m3 11 9-8 9 8v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1Z"/></symbol><symbol id="i-calendar" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="16" rx="2.5"/><path d="M16 3v4M8 3v4M3 10h18"/></symbol><symbol id="i-wallet" viewBox="0 0 24 24"><path d="M4 6h14a2 2 0 0 1 2 2v11H4a2 2 0 0 1-2-2V6a3 3 0 0 1 3-3h12"/><path d="M16 11h5v5h-5a2.5 2.5 0 0 1 0-5Z"/></symbol><symbol id="i-note" viewBox="0 0 24 24"><rect x="4" y="3" width="16" height="18" rx="2.5"/><path d="M8 8h8M8 12h8M8 16h5"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><rect x="3.5" y="3.5" width="17" height="17" rx="4"/><path d="m8 12 2.5 2.5L16.5 9"/></symbol><symbol id="i-pin" viewBox="0 0 24 24"><path d="M20 10c0 5-8 12-8 12S4 15 4 10a8 8 0 1 1 16 0Z"/><circle cx="12" cy="10" r="2.5"/></symbol><symbol id="i-moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></symbol><symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.66 6.34l1.41-1.41"/></symbol><symbol id="i-refresh" viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0 1 4"/><path d="M20 4v7h-7"/></symbol><symbol id="i-route" viewBox="0 0 24 24"><circle cx="6" cy="18" r="2"/><circle cx="18" cy="6" r="2"/><path d="M8 18h3a4 4 0 0 0 4-4v-4a4 4 0 0 1 4-4"/></symbol><symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/></symbol>
</svg>
Expand Down
Loading
Loading