• #家族旅行
  • #Astro
  • #Vue
  • #コスト比較
開発family-trips

家族旅行の費用比較ページを作る — 宿泊プランの実質宿泊費を逆算する

夏の家族旅行を1枚のサイトで管理したくて、専用プロジェクトを立ち上げた。 Astro + Cloudflare Pages の構成で、main に push すると勝手にデプロイが走る。 今日は予約が次々と確定していくのに合わせて、ページを「予定表」から「予約記録+費用比較ツール」へ作り替えた一日だった。

最後にたどり着いたのは「宿泊プランの料金から体験の費用を差し引いて、実質の宿泊費を逆算する」という考え方。 ここに気づくまで2回作り直したので、その試行錯誤を残しておく。

1. プロジェクトを立ち上げて dev サーバーを起動

まず pnpm dev でローカルサーバーを起動した。 ポートを別プロセスが掴んでいないか先に確認したかったが、PowerShell の deny ルールに弾かれたので、確認は飛ばして直接起動した。 数十秒待つと http://localhost:4321/ が立ち上がり、画面が出た。

旅行先・宿泊先・移動手段ごとにページが分かれていて、frontmatter に予算や予約状況を持たせる作りになっている。 ここに確定した予約を流し込んでいく。

2. レンタカーの取消条件をページに反映する

レンタカー会社のページのスクリーンショットを見ながら、取消手数料のルールをページに追記した。 「受取日の7日前までならキャンセル無料」という条件だったので、料金内訳の直後に「予約取消手数料(キャンセルポリシー)」のサブセクションを足した。

抽象的に「7日前まで無料」とだけ書いても後で読み返したときに計算し直す手間が増える。 そこで受取日から逆算した実際の無料期限まで併記させた。 追記後、dev サーバーのログを見ると Reloaded data from ... とだけ出てエラーは出ていない。ホットリロードが通った。

そのあと予約確定メールが届いたので、ページの数値とメールを突き合わせた。 内容は予約意図どおりだったが、料金が概算より安く確定していた。 frontmatter の移動費・予算、タイムライン、予約内容テーブル、料金内訳、TODO を確定値にまとめて直した。

このとき「移動」という1行を「飛行機」「レンタカー」の2行に分割したかったが、予算集計が壊れないか不安だった。 予算ページが何を参照しているか確認させたところ、集計は構造化オブジェクト側を見ていて、検討メモの category 文字列には依存していなかった。 分割しても集計は壊れないと分かったので、安心して2行に割った。

3. 宿泊プランの「実質宿泊費」を逆算する

今日の本題はここ。

宿泊先の候補に、プール・夕食・朝食ビュッフェがセットになった宿泊プランがあった。 最初は「このプランを使うと基準額からいくら得か」というシミュレーションを組んでいた。 ところが、これだと他の選択肢と横並びにならない。

そこで考え方を一度ひっくり返した。

宿泊プランの料金から、体験(プール・夕食・朝食)を個別に買ったらいくらかを差し引く。 残った金額が「実質の宿泊費」になる。

実質宿泊費が出れば、素泊まりの民泊や、他のホテルの素泊まりプランと同じ土俵で比べられる。 「このプランは食事もプールも込みで一見高いけど、体験を引くと宿泊そのものは2万円くらい」という見え方になる。

計算は逆算する向きが肝だった。

// 体験を個別購入したときの合計(プール2日分+夕食+朝食)
const experienceTotal = experiences.reduce((sum, e) => sum + e.price, 0)

// 確定したプラン料金から体験費を差し引いた残りが「実質の宿泊費」
const effectiveRoomCost = planPrice - experienceTotal

JSON データからは宿泊(room)の項目を外した。 宿泊費は固定値で持つのではなく、差し引きで毎回算出する方が筋が通る。 体験の単価を直せば実質宿泊費が自動で動くようにした。

判定ラベルも「お得/損」ではなく「泊まり得/日帰り得」に変えた。 プールを2日使うことを前提にして、宿泊者しか入れない施設と日帰りでも使える施設を分けて積み上げた。

やり過ぎて一度戻した

途中、不要になった旧プランの記述を消すついでに、お得感が見えるシミュレーション本体まで削ってしまった。 これは消し過ぎだった。 消すべきは古い前提の1ケースだけで、比較ロジックそのものは残すべきだった。

削除済みのコンポーネントを読み直して中身を確認し、シミュレーション部品とその import を復活させ、古いケースだけを外した。 削った変数(旧シナリオやラベル整形関数)の参照が本体に残っていないかを grep で確かめてからビルドに進んだ。

4. ビルドで確認してから GitHub へ push

確認の順序はいつも同じにしている。

  1. pnpm build を走らせてエラーが出ないか見る
  2. 全ページが生成されたか確認する
  3. 問題なければコミットして push

本番ビルドは成功し、全ページがエラーなく生成された。 インタラクティブな比較部品は client:only の Vue なので、ビルド時のコンパイルが通った時点で配線は確認できる。 ブラウザ実機での目視確認もしたかったが、Chrome DevTools の接続がタイムアウトを繰り返したので、そこはローカルで開いて目で見る運用に切り替えた。

main へ直接コミットし、GitHub に push した。 push と同時に Cloudflare Pages の自動デプロイが走る。

5. セキュリティレビュー

予約者の写真や画像をページに差し込む変更をしたので、Review this change for security vulnerabilities でセキュリティ観点のレビューもかけた。

新しく追加した :href:src のバインディングのデータ源を1つずつ辿ってもらった。 画像のソースはビルド時にバンドルされる静的 JSON で、攻撃者が触れる入力ではない。 Vue は javascript: URL を :href でサニタイズしないが、そこに攻撃者由来のデータが流れ込む経路がないので、実害のある脆弱性はなしという結論だった。

その後の変更(予約確定を反映した説明文の更新)は静的 JSON の日本語テキストを1行直しただけで、こちらもエントリポイントもシンクも増えていない。 費用比較ページのような静的データ中心の構成だと、攻撃面はデータの出どころに集約される。 「このデータは誰が書いたものか(作者か、攻撃者か)」を辿る作業がレビューの中心になると改めて分かった。

6. 積み残しを翌日へ

実質宿泊費の比較部品は作り直したばかりで、まだ未コミットのファイルが残っていた。 続きは翌日にしたかったので、積み残しをマークダウンのメモに残した。

正確な状態を残すために、まず git status で未コミットのファイルを確認してから、memo/2026-06-02/ にハンドオフメモを書いた。 「どのファイルが未コミットか」「比較ロジックをどう作り直したか」「次に何を確認すべきか」を書いておけば、翌日の自分が状況を思い出すコストが減る。

今日の学び

  • 「得か損か」のシミュレーションより、他の選択肢と同じ単位(実質宿泊費)に揃える方が比較として強い。逆算する向きに気づくまで2回作り直した
  • 集計が何を参照しているかを先に確認してから行を分割すれば、表示の都合でデータを割っても集計が壊れない
  • 静的サイトのセキュリティレビューは、シンクからデータ源へ辿って「作者由来か攻撃者由来か」を見極める作業に尽きる
  • 続きを翌日に回すときは、未コミットファイルの一覧と作り直した考え方をメモに残すと、翌朝の立ち上がりが速い