← そらいろのねこ

Webサービス 開発: 森下航 / 運用: 明治時代のせんとくん

ひなたカレンダー

日向坂46の予定を1枚に並べて、出演の前に知らせます。登録もログインも要りません。

実物を見る hinata-calendar.com

どうしてつくったか

紹介ページの冒頭。掲載中の予定495件・対象46人・毎朝更新

知っていることと、まにあうことは別だった

毎朝、日向坂46のその日の予定をまとめて投稿しています。続けるうちに、フォロワーさんからひとつの声をもらいました。「まとめは助かるけど、結局その時間を忘れてしまう。リマインドしてくれたら、もっと助かるのに」。たしかに、と思いました。予定を知っていることと、その時間にまにあうことは別のことです。

カレンダーの1日ぶん。区分と時刻つきの予定カードが並ぶ

Googleカレンダーに書き込む案を捨てた

v1.8までは「Googleカレンダーに予定を書き込み、ユーザーはそれを購読する」方式で考えていました。これは廃止しています。通知のタイミングと見た目がGoogleカレンダー側の設定に握られてしまい、「推しの予定だけ、好きな時間に」という肝心のところが作れないためです。書き込み先アカウントの管理とAPIの割り当ても運用に残ります。v2.0では自前でカレンダーを描き、通知はWeb Pushで自分から出す形にしました。

通知タイミングの設定。テレビ・ラジオ・配信は30分前、それ以外は当日8時

通知が届かなかった38件は、故障ではなかった

公開の2日後、運営画面を見ると、通知が届かなかった件数が38件と出ていました。調べると、公開以降の失敗は100%がno_subscription——通知をONにしたが購読を持たない人ぶんで、Pushそのものの失敗は1件もありませんでした。設計上の正常値です。ただし同時に、未発火のキュー4,075件のうち2,233件(23人・55%)が購読の無い人ぶんだと分かりました。1分あたりの送信上限20件には2026-09-09に実際に当たっています。購読の無い人が送信の枠を食う構造はそのまま残っていて、直していません。

できることの説明カード2枚

推しを選ぶだけで、少し前に教えてくれる

推しを選ぶと、その出演の前に通知が届きます。テレビ・ラジオ・配信は開始の15〜60分前から選べ、舞台やライブと終日の予定はその日の朝8時です。推しを選んでもほかの予定は消えません。カレンダーは全件を出したまま、通知だけを絞る作りにしてあります。深夜の番組は番組表と同じ感覚で前日の欄に並びます。

設計の全体像

ひなたカレンダーの構成図 供給側のHinatazakaScheduleが毎朝スナップショットをPOSTし、Workerが受けてD1に入れる。毎分のCronが発火時刻の来た通知を拾い、Web Pushで端末へ送る。閲覧はStatic Assetsが配るSPAから行う。 HinatazakaSchedule 別プロジェクト(供給側) 毎朝7時すぎ POST Cloudflare Workers /api/ingest 契約v3で受ける Static Assets(SPA) 画面を配る Cron(毎分) fire_at <= now 未送信を拾って送る 1分あたり20件まで D1 予定・設定・キュー Web Push 端末 Service Worker が受ける 匿名トークンで設定を持つ 閲覧
集めるのは別プロジェクト。ここは受け取って、並べて、時間が来たら送るだけです。

予定を集める仕事は持っていません。既存の HinatazakaSchedule が毎朝スナップショットを POST /api/ingest で投げ、こちらは受け取ってD1に入れます。画面は同じWorkerがStatic Assetsとして配り、/api/* だけをWorkerのコードへ回しています。通知は発火時刻を先に計算してキューに積み、毎分のCronが時間の来たものを拾ってWeb Pushで送ります。

つくりかた

暗号方式で、ライブラリを選び直した

Web Pushの本文暗号化には古い aesgcm(draft-04)と現行の aes128gcm(RFC 8291)があり、AppleのWeb Pushは後者しか受け付けません。ところがCloudflare Workers向けとして広く使われているライブラリは、2026-08-31に実装を展開して確かめた時点でどれも aesgcm でした。そこで @mmmike/web-push を版固定で採っています。ここが静かに戻るとiOSだけが黙って死ぬので、test/push.test.ts がヘッダと本体を実際に組み立てて Content-Encoding を見張っています。送信は DeliveryAdapter の裏に隔離してあり、最悪はRFC 8291を自前実装に差し替えられます。

区分を増やさず、通知を2つに畳んだ

供給側の category は閉じた列挙ではなく自由文字列で、実測291件のうち舞台81・雑誌19・イベント29の計129件(44%)が仕様上の「その他」に落ちました。区分を細かくする道もありましたが、通知バケットを2つに畳むほうを選んでいます。テレビ・ラジオ・配信は開始前、それ以外は当日8時です。開演19:30の舞台が朝8時に通知されるのは意図した挙動で、この種の予定は「直前」より「その日の朝に思い出す」ほうが合うという判断です。原文は category_raw に残してあるので、後から細かくしたくなっても情報は消えていません。

分担

着手前に、同じことをする実装をワークスペース全体から探しています。Workers+D1の運用はHinatazakaAnalytics、SPAフォールバックはNeage、Web Push/VAPIDはRiverheadに前例があり、それぞれ踏襲・再利用しました。日向坂の予定収集はHinatazakaScheduleがあるので再スクレイピングはしていません。実装はClaude Codeと対話しながら進め、供給側との受け渡しは docs/ingest-contract.md に契約として先に文章で固めてから両側を直しています。人間が持っているのは、何を作らないかの決定と、実データを見てからの判断です。

構成: Cloudflare Workers(Static Assets+同一WorkerのAPI)/ D1 / Workers Cron / Web Push(VAPID・RFC 8291)/ Vite+React+TypeScript+Tailwind。テストは Vitest の29ファイル。

数字と運用

掲載中の予定
495
対象メンバー
46人(現役とOG)
公開中の版
v1.13.1
開発期間
26日(2026-08-28〜09-22)
コミット
88
D1マイグレーション
16

供給は毎朝7時すぎに1回入り、受け取った時刻はヘッダーに出ます。通知のCronは毎分動いていて、確認した時点の遅れは12秒でした。運営画面はローカル専用で、本番のデータは読むだけです。予定の中身は毎朝ひとつずつ人の目で確認し、漏れや誤りを直してから届けています。

数字は2026-09-22に本番の /api/health/api/stats とリポジトリで測った値です。通知ON数など公開していない数字は載せていません。

関連リンク

次のプロジェクト hinata-analytics →