17
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Webで作る!予定通知機能付き「旅のしおり」🔖

17
Posted at

はじめに

LIFULL Advent Calendar 2025 8日目の記事です。
普段は LIFULL HOME'S不動産査定ホームズマンション売却 の開発に携わっています。

本記事では、HTML + Cloudflare Pagesで作成した予定通知機能付き「旅のしおり」を紹介します。

しおり.png

最近、SNSを中心に「旅のしおり」が流行しています。知人との旅行前に、手作りのしおりを作って予定を共有する、というスタイルをよく見かけるようになりました。


同期エンジニアで開発合宿を開催するにあたり、「旅のしおりを作成したい!」となったのですが、紙のしおりは素敵な一方で、

  • 予定変更のたびに修正・再配布が必要
  • 人数分印刷して配る手間がかかる
  • 当日うっかり忘れてしまう可能性もある

といった運用上の課題が想定されました。

そこで今回は、旅のしおりをHTMLで作成して、Cloudflare Pagesで無料公開するという方法を試してみました。この方法であれば、

  • いつでも簡単に修正できる
  • URLを送るだけで全員に共有できる
  • スマートフォンからいつでも確認できる
  • 完全無料でホスティング可能

という、紙のしおりの弱点をほぼすべて解消できます。

さらにこの記事の後半(第2章)では、設定した予定時間に自動で通知を送る機能も追加していきます。

この記事で作成するもの

第1章:HTMLで旅のしおりを作成してCloudflare Pagesで公開

旅のしおり
- スケジュール(日付・時間・内容)
- 宿泊先の情報
- 持ち物リスト
- 近隣の店舗情報
- メンバー
など

第2章:Web Push通知機能を追加

予定の時間に自動で通知
例:
- 13時 → 「集合時間👥」
- 19時 → 「夕食🍚」

第1章:HTMLで旅のしおりを作る

完成イメージ

スマートフォン

SP版しおり1   SP版しおり2

デスクトップ

PC版しおり1   PC版しおり2

必要なもの

  • GitHubアカウント(無料)
  • Cloudflareアカウント(無料)
  • テキストエディタ(VS Codeなど)

基本的な旅のしおりHTML

HTMLで旅のしおりを作成します。以下は最低限のファイル構成の例です。

your-project/
├── public/
│   └── index.html   # メインページ
└── README.md

index.htmlには以下のような内容を記述します。

  • スケジュール(日付・時間・内容)
  • 宿泊先の情報
  • 持ち物リスト
  • 近隣の店舗情報
  • メンバー

Cloudflare Pagesにデプロイ

  1. GitHubにリポジトリを作成してプッシュ
  2. Cloudflare Dashboard → Workers & PagesCreate application
  3. Pagesタブ → Connect to Git
  4. リポジトリを選択してデプロイ

詳しい手順はCloudflare公式ドキュメントを参照してください。

これで、GitHubリポジトリ名に基づいた次の形式のURLで旅のしおりが公開されます。
https://<リポジトリ名>.pages.dev

第2章:予定時間に通知を送る機能を追加

旅のしおりを作ったら、「予定の時間に通知が来たら便利だな」と思いませんか?

この章では、Web Push APIを使って、設定した時間に自動で通知を送る機能を追加します。

完成イメージ

  1. サイトで「通知を有効にする」ボタンをクリック
  2. 設定した日時になると自動でブラウザ通知が届く

iOS

iOS通知

Android

Android通知

Mac

Mac通知

技術スタック

  • Cloudflare Pages Functions:サブスクリプション登録API
  • Cloudflare Workers:通知送信処理
  • Cloudflare KV:スケジュールとサブスクリプション情報の保存
  • Web Push API:ブラウザへのプッシュ通知
  • Service Worker:クライアント側の通知制御

追加で必要なもの

  • Node.js環境(ローカル開発用)
  • 少しのJavaScriptの知識

システム構成

動作の流れ

  1. ユーザーがブラウザで通知を許可
  2. Service Workerが登録され、サブスクリプション情報をPages Functionsに送信
  3. スケジュールとサブスクリプション情報をCloudflare KVに保存
  4. Cron Workerが毎分KVをチェック
  5. 現在時刻とスケジュールが一致したら、ユーザーに通知を送信

実装手順

ステップ1:VAPID鍵の生成

Web Pushには認証用のVAPID鍵が必要です。

npx web-push generate-vapid-keys

出力例:

Public Key: BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LQ...
Private Key: UUxI4O8DDsznPBCWGE7hMCi5Y2f9oXLsK-KJQgRNq...

この2つのキーを安全に保存しておきます。

ステップ2:Cloudflare KVの作成

  1. Cloudflareダッシュボード → Workers & PagesKV
  2. Create a namespaceをクリック
  3. Namespace Name:SUBSCRIPTIONS
  4. 作成後、Namespace IDをメモ

ステップ3:Pagesプロジェクトの準備

プロジェクト構造

your-project/
├── functions/
│   └── api/
│       └── subscribe.js   # サブスクリプション登録API
├── public/
│   ├── manifest.json      # PWA用マニフェスト
│   ├── icon-192.png       # アプリアイコン 192x192
│   ├── sw.js              # Service Worker
│   └── index.html         # メインページ
└── README.md

public/manifest.json

iPhoneでホーム画面に追加してPWAとして動作させるために必須です。

{
  "name": "開発合宿の旅のしおり",
  "short_name": "旅のしおり",
  "description": "開発合宿用に作成した予定確認と通知ができる旅のしおりWebアプリ",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4285f4",
  "icons": [
    {
      "src": "/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ]
}

public/icon-192.png

アイコン用の192x192pxのPNG画像を用意してください。

functions/api/subscribe.js

// サブスクリプション登録用API
export async function onRequestPost(context) {
  const { request, env } = context;
  
  try {
    const { subscription, schedules } = await request.json();
    
    const subscriptionId = crypto.randomUUID();
    
    await env.SUBSCRIPTIONS.put(subscriptionId, JSON.stringify({
      subscription,
      schedules,
      createdAt: new Date().toISOString()
    }));
    
    return new Response(JSON.stringify({ 
      success: true, 
      id: subscriptionId 
    }), {
      headers: { 
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*'
      }
    });
  } catch (error) {
    return new Response(JSON.stringify({ 
      success: false, 
      error: error.message 
    }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' }
    });
  }
}

export async function onRequestOptions(context) {
  return new Response(null, {
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type'
    }
  });
}

public/sw.js

// Service Worker
self.addEventListener('push', function(event) {
  const data = event.data ? event.data.json() : {};
  const title = data.title || '旅のしおり';
  const options = {
    body: data.body || '予定の時間です',
    icon: data.icon || '/icon-192.png',
    badge: data.badge || '/badge-72.png',
    vibrate: [200, 100, 200],
    tag: 'itinerary-notification',
    data: {
      url: data.url || '/'
    }
  };

  event.waitUntil(
    self.registration.showNotification(title, options)
  );
});

self.addEventListener('notificationclick', function(event) {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(event.notification.data.url)
  );
});

self.addEventListener('install', function(event) {
  self.skipWaiting();
});

self.addEventListener('activate', function(event) {
  event.waitUntil(clients.claim());
});

public/index.html

<head>タグにmanifest.jsonとアイコンのリンクを追加してください。

<head>
  <!-- 既存のmeta tagなど -->
  
  <!-- PWA用メタタグ -->
  <link rel="manifest" href="/manifest.json">
  <meta name="theme-color" content="#4285f4">
  
  <!-- iOS用 -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="default">
  <meta name="apple-mobile-web-app-title" content="旅のしおり">
  <link rel="apple-touch-icon" href="/icon-192.png">
  
  <!-- その他のアイコン -->
  <link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
</head>

<body>
  <!-- 通知登録ボタン -->
  <button id="enable-notifications">通知を有効にする</button>
  <div id="notification-status"></div>

  <script>
  const VAPID_PUBLIC_KEY = 'YOUR_PUBLIC_VAPID_KEY_HERE'; // ステップ1のPublic Key

  function urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - base64String.length % 4) % 4);
    const base64 = (base64String + padding)
      .replace(/\-/g, '+')
      .replace(/_/g, '/');

    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);

    for (let i = 0; i < rawData.length; ++i) {
      outputArray[i] = rawData.charCodeAt(i);
    }
    return outputArray;
  }

  async function registerServiceWorker() {
    if (!('serviceWorker' in navigator)) {
      alert('このブラウザはService Workerに対応していません');
      return null;
    }

    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      console.log('Service Worker registered');
      return registration;
    } catch (error) {
      console.error('Service Worker registration failed:', error);
      return null;
    }
  }

  async function subscribeToPushNotifications() {
    const statusDiv = document.getElementById('notification-status');
    statusDiv.textContent = '設定中...';

    const registration = await registerServiceWorker();
    if (!registration) return;

    const permission = await Notification.requestPermission();
    if (permission !== 'granted') {
      statusDiv.textContent = '通知が許可されませんでした';
      return;
    }

    try {
      const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
      });

      const response = await fetch('/api/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          subscription: subscription,
          schedules: [
            { date: '2025-11-24', time: '13:00', message: '13:00 集合時間👥' },
            { date: '2025-11-24', time: '15:00', message: '15:00 開発セッション1💻' },
            { date: '2025-11-24', time: '19:00', message: '19:00 夕食🍚' }
          ]
        })
      });

      const result = await response.json();
      
      if (result.success) {
        statusDiv.textContent = '通知が有効になりました!';
        localStorage.setItem('subscriptionId', result.id);
      } else {
        statusDiv.textContent = '登録に失敗しました';
      }
    } catch (error) {
      console.error('Subscription failed:', error);
      statusDiv.textContent = 'エラーが発生しました';
    }
  }

  document.addEventListener('DOMContentLoaded', () => {
    document.getElementById('enable-notifications')
      .addEventListener('click', subscribeToPushNotifications);
  });
  </script>
</body>

ステップ4:追加ファイルをデプロイ

ステップ3で作成したファイルをGitHubに反映させます。
Cloudflare Pagesが自動的に再デプロイを開始します。

ステップ5:PagesのKVバインディング設定

  1. デプロイ完了後、SettingsFunctions
  2. KV namespace bindingsAdd binding
    • Variable name:SUBSCRIPTIONS
    • KV namespace:ステップ2で作成したものを選択
  3. Save

ステップ6:Cron Worker(通知送信用)の作成

ローカルでWorkerプロジェクトを作成

mkdir travel-notifications-worker
cd travel-notifications-worker
npm init -y
npm install wrangler @block65/webcrypto-web-push

wrangler.toml

name = "travel-notifications-cron"
main = "src/index.js"
compatibility_date = "2025-11-23"

kv_namespaces = [
  { binding = "SUBSCRIPTIONS", id = "{KVのNamespace ID}" }
]

[triggers]
crons = ["* * * * *"]  # 1分ごと(テスト用)

[vars]
VAPID_PUBLIC_KEY = "BEl62iU..." # ステップ1のPublic Key

src/index.js

import { buildPushPayload } from '@block65/webcrypto-web-push';

export default {
  async scheduled(event, env, ctx) {
    // 日本時間に変換(UTC+9)
    const now = new Date();
    const jstOffset = 9 * 60 * 60 * 1000;
    const jstDate = new Date(now.getTime() + jstOffset);
    
    const currentDate = jstDate.toISOString().split('T')[0];
    const currentTime = `${jstDate.getHours().toString().padStart(2, '0')}:${jstDate.getMinutes().toString().padStart(2, '0')}`;
    
    console.log(`Cron triggered at JST: ${currentDate} ${currentTime}`);
    
    // VAPID設定
    // subjectには管理者の連絡先を指定します(Web Push仕様で必須)
    const vapid = {
      subject: 'mailto:your-email@example.com', // 自分のメールアドレス or サイトURLに変更
      publicKey: env.VAPID_PUBLIC_KEY,
      privateKey: env.VAPID_PRIVATE_KEY
    };
    
    try {
      // KVから全てのサブスクリプションを取得
      const list = await env.SUBSCRIPTIONS.list();
      console.log(`Found ${list.keys.length} subscriptions`);
      
      for (const key of list.keys) {
        const data = await env.SUBSCRIPTIONS.get(key.name);
        if (!data) continue;
        
        const { subscription, schedules } = JSON.parse(data);
        
        // 現在の日時に一致するスケジュールを探す
        const matchingSchedule = schedules.find(s => 
          s.date === currentDate && s.time === currentTime
        );
        
        if (matchingSchedule) {
          console.log(`Sending: ${matchingSchedule.message}`);
          
          try {
            const message = {
              data: JSON.stringify({
                title: '旅のしおり',
                body: matchingSchedule.message,
                icon: '/icon-192.png',
                url: '/'
              }),
              options: {
                ttl: 86400,
                urgency: 'high'
              }
            };
            
            const payload = await buildPushPayload(message, subscription, vapid);
            const response = await fetch(subscription.endpoint, payload);
            
            if (response.ok) {
              console.log('Notification sent!');
            } else {
              console.error(`Failed: ${response.status}`);
              
              // 410 = サブスクリプションが無効
              if (response.status === 410) {
                await env.SUBSCRIPTIONS.delete(key.name);
              }
            }
          } catch (error) {
            console.error('Send failed:', error.message);
          }
        }
      }
    } catch (error) {
      console.error('Cron error:', error);
    }
  }
};

デプロイ

# VAPID Private Keyをセキュアに設定
npx wrangler secret put VAPID_PRIVATE_KEY
# プロンプトが出たらPrivate Keyを貼り付け

# デプロイ
npx wrangler deploy

ステップ7:動作確認

通知の有効化

  1. デプロイしたPagesサイトにアクセス
  2. 「通知を有効にする」ボタンをクリック
  3. 通知を許可
  4. 「通知が有効になりました!」と表示されればOK

iPhone(iOS)の場合

iPhoneでWeb Push通知を受け取るには、iOS 16.4以降が必要です。

設定手順

  1. ブラウザで旅のしおりサイトを開く
  2. 共有ボタン→ ホーム画面に追加をタップ
  3. PWA(Progressive Web App)としてインストール
  4. ホーム画面から起動したアプリで「通知を有効にする」をタップ
  5. 通知の許可ダイアログが表示されるので「許可」をタップ
iOS通知許可

注意点

  • ブラウザでは通知を受け取れません
  • ホーム画面に追加したPWAアプリからのみ通知を受け取れます
  • アプリを完全に終了していても通知は届きます

実際の通知

設定時刻になると、ロック画面や通知センターに通知が表示されます。

iOS通知

Androidの場合

設定手順

  1. ブラウザで旅のしおりサイトを開く
  2. 「通知を有効にする」ボタンをタップ
  3. 通知の許可ダイアログが表示されるので「許可する」をタップ
Android通知許可

注意点

  • ブラウザを閉じていても通知は届きます

実際の通知

設定時刻になると、ロック画面や通知バーに通知が表示されます。

Android通知

Macの場合

設定手順

  1. ブラウザで旅のしおりサイトを開く
  2. 「通知を有効にする」ボタンをクリック
  3. 通知の許可ダイアログが表示されるので「許可する」をタップ
Mac通知許可

注意点

  • ブラウザが起動している必要があります
  • ブラウザを完全に終了すると通知は届きません

実際の通知

設定時刻になると、デスクトップ右上に通知バナーが表示されます。

Mac通知

料金について

すべてCloudflareの無料プランで運用可能です。

  • Pages:無料
  • Workers:100,000リクエスト/日まで無料
  • KV:100,000 read、1,000 write/日まで無料
  • Cron Triggers:追加料金なし

実際の使用量

テスト用の設定(1分ごと実行)の場合

  • 1,440リクエスト/日(60回/時間 × 24時間)
  • Workersの無料枠に対して1.44%の使用量

十分に余裕があるため、無料枠内で安心して運用できます。


本番運用時の最適化

テスト完了後は、必要な時刻だけ実行するように変更することをお勧めします。

# wrangler.toml
[triggers]
crons = ["0 4,5,6,10,12 * * *"]  # UTC 04:00, 05:00, 06:00, 10:00, 12:00
# = JST 13:00, 14:00, 15:00, 19:00, 21:00

この設定では

  • 1日5リクエストのみ(予定の時刻のみ実行)
  • Workersの無料枠に対して0.005%の使用量

さらにリクエスト数を抑えつつ、必要な通知はすべて送信できます。

まとめ

この記事では、HTML + Cloudflare Pagesで作る「旅のしおり」に、Web Push APIを使った予定通知機能を追加する方法を紹介しました。

17
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?