Idea, Concept, and Technology

アイデアを形に

Claude Codeに定点撮影アプリを作らせてApp Storeに公開するまで

はじめに

2026年9月19日の朝に空のフォルダから作り始めた定点撮影アプリ「Then & Now」が、9月23日にApp Storeで公開されました。

Then & Now - 定点撮影カメラ

Then & Now - 定点撮影カメラ

  • icot
  • 写真/ビデオ
  • 無料
apps.apple.com

実装はClaude Code (Fable 5.1) に任せ、私がやったのは最初のプロンプトを書くこと、実機で触って気づいたことを伝えること、App Store Connectの操作、そしてアイコン画像をGeminiに描かせることでした。コードは1行も手で書いていません。

「AIにアプリを作らせてみた」という記事はもう珍しくありませんが、審査に出して公開されるところまで通すと、コードを書く以外の作業が思ったより多いことが分かります。位置合わせのアルゴリズムで一工夫が必要だったこと、実機で初めて出た不具合、スクリーンショットの素材問題、そのあたりも含めて書いていきます。

作ったもの

同じ場所から同じ構図で撮り続ける「定点撮影」に特化したカメラアプリです。ベランダの木の季節変化、リフォームの進行、子どもの背丈のような、月単位・年単位の変化を写真で残すためのものです。

定点撮影が続かない理由は、構図を合わせるのが難しいことと、撮りに戻るのを忘れることの2つだと考えました。既存のアプリの多くは「前の写真を半透明で重ねる」までは実装していますが、そこから先はユーザーの手作業です。そこで、この2点に機能を絞りました。

  • 自動位置合わせ: 前回の写真とライブ映像のズレをリアルタイムに計算し、「少し右に」「少し近づく」のように案内する。ズレが閾値内に入って0.6秒続いたら自動でシャッターを切る
  • 撮りどきの通知: 前回から N 日経ったら通知する。その場所に戻ったらジオフェンスで通知する

そのほかにタイムラインでのパラパラ再生、ビフォー・アフターの比較スライダー、安定化つきのタイムラプス書き出し、Face IDによるロック、ウィジェット、Siriショートカットが入っています。無料で、機能制限はなく、収益化は投げ銭だけです。

技術的な前提は、iOS 26以上、Swift 6のstrict concurrency、SwiftUI、SwiftData、画像処理はVisionフレームワークのみでOpenCVには依存しない、という構成にしました。

最初のプロンプトで決めておいたこと

プロンプトは日本語で、およそ200行書きました。振り返ると、効いたのは機能の羅列ではなく、次の3つの区画でした。

「確定事項」を冒頭にまとめる

最低対応OS、言語、永続化、画像処理ライブラリ、収益化、ローカライズ、写真の保存先。こういう決め打ちを「勝手に変えないこと」という見出しの下に並べました。実装の途中で「OpenCVを入れたほうが精度が出そうです」のような提案で脱線しないための柵です。実際、位置合わせの精度で苦労した場面がありましたが、Visionの範囲内で解決策を探してくれました。

マイルストーンごとに区切って報告させる

M1 (基礎)、M2 (位置合わせ)、M3 (リマインダー)、M4 (仕上げ) の4段階に分け、各段階で xcodebuild によるビルドとテストを通してから報告することを指示しました。結果として、初日の午前中にM1からM4までが一通り動く状態になりました。段階ごとに区切ったことで、報告を読んで方向を確認しながら進められました。

「やらないこと」を書く

クラウド同期、SNS連携、フィルター、有料プラン、長いチュートリアル。これらは「提案も実装もしない」と明記しました。自律的に動くエージェントは、放っておくと親切心で機能を足していきます。やらないことを書いておくと、その分の判断を毎回しなくて済みます。

位置合わせはVisionで足りた、ただし一工夫が要った

ここが一番の技術的な見どころです。

Visionには VNTranslationalImageRegistrationRequest (平行移動) と VNHomographicImageRegistrationRequest (ホモグラフィ) という2つの画像位置合わせAPIがあります。ライブ映像を約5fpsに間引き、長辺512pxのグレースケールに落としてから、まず平行移動を求め、手が落ち着いたらホモグラフィを求めて平行移動・回転・拡大率の3成分に分解する、という設計です。

ホモグラフィは大きなズレを過小評価する

既知の変換 (30px移動、5度回転、1.1倍) を適用した画像ペアをテストフィクスチャとして用意し、算出値を検証するようにプロンプトで指示していました。この検証で分かったのが、ホモグラフィ単体では30pxの移動が12pxとして返ってくることでした。回転と拡大は正確でしたが、移動量が小さすぎるのです。おそらく反復的な最適化が局所解で止まるためだと思われます。

Claude Codeが取った対策は、まず平行移動の推定値でフレームをずらしてからホモグラフィを解き、あとで2つを合成する、というものでした。これで30pxの移動が29.99pxとして返るようになりました。

信頼度は自分で計算する

もう1つのハマりどころは、Visionのregistration APIは信頼度を返さないことです。夜景や白い壁でも、何かしらの行列は返ってきます。それをそのまま使うと、でたらめな矢印が表示されます。

そこで、推定した変換で2枚を実際に重ね、正規化相互相関で「本当に一致しているか」を確かめる検証層を入れました。相関が閾値を下回れば「解なし」として扱い、3秒間解が出なければ「この場面では自動ガイドを使えません。重ねて手で合わせてください」と表示して手動モードに落ちます。黙って動かなくなるのが最悪なので、フォールバックには必ず理由を出す、というのは最初のプロンプトから入れていた要件です。

この設計はあとで実物の写真で試すことになります。2021年12月の夜に撮った目黒川の写真と、2026年9月の昼に撮った同じ場所の写真を通したところ、想定どおり「解なし」になりました。昼と夜では画素の特徴が対応しないためで、手動モードに落ちる正しい動きです。ただ、最初の1フレームだけ「右に75%ずれている」というでたらめな推定が検証を通っていました。2枚の重なりがごくわずかで、その部分がたまたま相関していたのが原因です。重なりの割合を条件に加えて直しました。

実機で初めて分かったこと

シミュレータにはカメラがないので、Claude Codeはシミュレータ専用の合成シーン (手ぶれのように漂う図形) を用意して、ガイドと自動シャッターの流れを検証していました。それでも、実機に入れて初めて出た問題がいくつかあります。

しばらく撮っているとプレビューが止まる

原因を実機のログで確定できたわけではありませんが、一番疑わしかったのは、SwiftUIの再描画のたびに新しい AVCaptureSession を作っては捨てていたことでした。カメラ画面の @State で CameraModel() を初期化し、その init でセッションを作っていたため、撮影するたびに親ビューが再描画され、使われないセッションが積み上がっていたのです。セッションの生成を start() に遅らせ、あわせて中断・実行時エラーの監視、フレームが3秒来なかったら再起動する監視、発熱時の処理頻度の抑制を入れました。修正後は再発していません。

1枚目を撮ると、すぐ2枚目が撮られる

これは設計の穴でした。1枚目を撮った直後にその写真が基準になり、端末はまだ同じ場所を向いているので、位置合わせは完全一致の状態になります。0.6秒後に自動シャッターが切れて、不要な2枚目が保存されます。新規ユーザーが全員最初に踏む問題だったので、審査に出した翌日に取り下げてビルド2で再提出しました。

権限ダイアログが3回出る

場所リマインダーをオンにすると、位置情報「使用中のみ」→「常に許可」→通知、と3つのダイアログが続けて出ていました。最初のプロンプトで「まず使用中のみを要求し、常には後から」と書いたのを、そのまま実装した結果です。iOSは requestAlwaysAuthorization を最初から呼んでも、その場では1つのダイアログしか出さず、「常に許可のままにしますか?」の確認は後日バックグラウンドで位置情報を使ったときに自分で出してくれます。2回で済むように変えました。

「ダイアログが出なかった」を時間で判断していた

許可したのにトグルがオンにならないことがある、という現象がありました。許可ダイアログの結果を待つ処理が「0.7秒待ってアプリがアクティブなままなら、ダイアログは出なかった」と判断していたのが原因です。位置情報のダイアログは地図つきで、表示に0.7秒以上かかることがあります。許可状態の変化そのものをポーリングする方式に変えました。

こうした問題は、どれも私が実機で触って「こうなった」と伝えただけで、原因の特定と修正はClaude Code側でした。ログが取れない実機の現象でも、コードから原因の候補を挙げてつぶしていく進め方で解決できています。

ストアに出すまでの作業もほぼ任せられた

コードが動いてからが長い、というのが今回の実感です。

アイコンはGeminiに描かせた

Claude Codeに「Gemini向けのプロンプトをください」と頼み、それをGeminiに貼りました。iOSが角丸マスクをかけるので画像側で角を丸めない、文字を入れない、透過なし、60ptに縮小しても読めること、という制約が入ったプロンプトでした。出てきた案をClaude Codeに渡すと、iOSの角丸をかけて60ptまで縮小した比較画像を作って評価してくれます。「照準を太くしたらプラス記号に見えて写真追加アプリに見える」という指摘は、自分では気づかなかったと思います。

スクリーンショットは写真がなくて詰まった

ストアのスクリーンショットには「昔と今」の写真のペアが必要ですが、作ったばかりのアプリにそんな写真はありません。フリー素材のタイムラプス動画からコマを抜く案も試しましたが、13秒の動画では変化が小さすぎて使えませんでした。

最終的に、Claude Codeがシミュレータ専用のコードとしてフラットなイラストのシーン (季節が変わる木、建っていく家、育つ菜園) を描き、過去日付のデモデータを流し込んで、6.9インチのシミュレータで6画面を日英で撮影、見出しつきの1320x2868に整形するところまでやりました。この間、私は写真を1枚も用意していません。

審査に向けた細かい確認

  • ローカライズ: すべてのUI文字列をString Catalogに入れ、コンパイラが抽出したキーと日本語訳の有無を照合するスクリプトで漏れを検出。最終的に186キー
  • プライバシーマニフェスト: UserDefaults と systemUptime の使用理由を PrivacyInfo.xcprivacy に宣言。これがないと ITMS-91053 の警告が来ます
  • 投げ銭のテスト: Sandboxアカウントと実機での検証が面倒だと言ったら、StoreKitTest でローカルの .storekit 設定に対して購入フローを自動テストする形にしてくれました。審査用のスクリーンショットも、テスト中にセッションを開いたままにしてシミュレータから撮っています
  • プライバシーポリシー: コードを読んで実態と照合したうえで日英のMarkdownを作成。iCloudバックアップに写真が含まれることも明記しています

「ネットワーク通信ゼロ」は緩めた

最初のプロンプトで「URLSessionを使わない、通信を一切しない」と書いていましたが、v1.1で地図一覧を作ろうとしたところ、MapKitの地図タイル取得がこれに抵触することが分かりました。よく考えると、投げ銭のStoreKitもAppleのサーバーと通信しています。守りたかったのは「開発者のサーバーを持たない、データを開発者にも第三者にも送らない」であって、パケットの有無ではありません。原則をそう書き換えて、プライバシーポリシーの文言も直しました。確定事項として書いたものでも、目的に照らして緩めてよいものはある、という学びです。

まとめ

Claude Codeに定点撮影アプリを作らせて公開するまでで分かったことをまとめます。

  • 約5,600行のSwift、85件のテスト、186キーの日英ローカライズを、コードを手で書かずに公開まで持っていけた
  • プロンプトで効いたのは、確定事項・マイルストーン・やらないこと、の3つの区画
  • Visionのホモグラフィは大きなズレを過小評価する。平行移動で先にずらしてから解くと正確になる
  • Visionのregistration APIは信頼度を返さない。推定結果を画素で検証する層を自分で持つ必要がある
  • シミュレータで検証できないカメラまわりは、実機で触って現象を伝えれば原因まで辿ってくれた
  • ストアに出す作業 (アイコン、スクリーンショット、マニフェスト、IAPのテスト) は、コードを書く時間より長かった
  • 「通信ゼロ」のように強すぎる確定事項は、目的に照らして緩めてよい

一番の収穫は、実機で触って「こうなった」と伝えるだけで直っていく体験でした。自分でログを取って原因を追う代わりに、症状の説明に時間を使う。この配分に慣れると、作れるものの範囲がかなり変わりそうです。

参考リンク

Then & Now プライバシーポリシー

プライバシーポリシー

Then & Now · 2026年9月20日 施行

データは一切収集しません。

Then & Now にはアカウントも、アクセス解析も、広告も、サードパーティ製のコードもありません。開発者のサーバーは存在せず、アプリがデータを開発者や第三者に送信することはありません。写真、保存した場所、設定は、すべてお使いの端末の中にとどまります。

端末の中に保存されるもの

データ 用途
写真 撮影した写真は、アプリ専用の保存領域に保存されます。「写真」ライブラリには追加されません。写真に位置情報は含まれません。
撮影対象の情報 名前、リマインダーの設定、ロックの設定。アプリ内のローカルデータベースに保存されます。
場所 場所リマインダーをオンにした場合、その1か所の座標と半径を保存します。その場所に戻ったことを iOS がアプリに知らせるために使います。
設定 オーバーレイの透明度、自動シャッター、許容範囲、App Lock の設定。

これらが開発者や第三者に送信されることはありません。このアプリにはサーバーがないため、開発者がこれらのデータにアクセスする手段もありません。

アプリが求める許可

いずれも、その許可を必要とする機能を使うときに初めて求めます。許可しなくても、アプリのほかの機能は使えます。

許可 理由
カメラ 写真を撮るため、およびカメラの映像を基準の写真と比較するために使います。映像は端末内で解析し、その場で破棄します。
位置情報 場所リマインダーにのみ使います。アプリを閉じている間も、その場所に戻ったことを iOS が検知できるよう「常に許可」が必要です。移動の追跡や記録は行いません。iOS から知らされるのは、保存した場所に入った瞬間だけです。
通知 設定したリマインダーを届けるために使います。端末内で作成するローカル通知であり、サーバーから送るプッシュ通知ではありません。
Face ID / Touch ID アプリやロックした撮影対象のロックを解除するために使います。認証は iOS が行い、アプリに伝わるのは成功したかどうかだけです。生体情報がアプリに渡ることはありません。
「写真」への追加 書き出したタイムラプスビデオで「『写真』に保存」を選んだときにのみ使います。アプリが写真ライブラリを読み取ることはできません。

アプリが利用する Apple のサービス

このアプリが利用するネットワークサービスは、Apple が提供するシステムサービスだけです。これらは Apple のプライバシーポリシーに従って扱われます。任意の投げ銭のための App Store と、撮影対象の背景に地図を描くための Apple マップがこれに当たります。地図を開くと、表示している範囲の地図タイルが取得されます。撮影対象の情報そのものが送信されることはありません。

投げ銭

任意の投げ銭は、App Store を通じて Apple が処理します。お支払い情報が開発者に渡ることはありません。Apple から開発者に、個人を特定できない集計済みの販売実績が提供されることがあります。投げ銭によって解放される機能はありません。

ウィジェット、Siri、ショートカット

ウィジェットは、アプリが端末内で共有する簡単な要約 (撮影対象の名前と、最後に撮影した日付) を読み取ります。ショートカットと Siri のフレーズには、入力された撮影対象の名前が使われます。いずれも端末上で iOS が処理します。

バックアップ

iCloud バックアップやコンピュータへのバックアップを利用している場合、ほかのアプリと同様に、このアプリのデータ (写真など) もそのバックアップに含まれます。バックアップはお客様の Apple アカウントに属するもので、Apple のプライバシーポリシーと端末の設定に従って扱われます。開発者がバックアップにアクセスすることはできません。

データの削除

  • アプリ内でショットや撮影対象を削除すると、その写真はただちに端末から削除されます。
  • アプリを削除すると、アプリが保存したすべてのデータが削除されます。
  • 各種の許可は、「設定」アプリでいつでも変更できます。

お子様について

このアプリは、お子様を含め、誰からもデータを収集しません。

このポリシーの変更

将来のバージョンでデータの取り扱いが変わる場合は、そのバージョンの公開前にこのページを更新し、上記の施行日を改めます。

お問い合わせ

このポリシーに関するお問い合わせ: takaakiyayoi@gmail.com


© 2026 弥生 隆明

Then & Now Privacy Policy

Privacy Policy

Then & Now · Effective 20 September 2026

We do not collect any data.

Then & Now has no account, no analytics, no ads and no third-party code. We run no server, and the app sends nothing to us or to anyone else. Your photos, the places you save and your settings stay on your device.

What stays on your device

Data What it is used for
Photos The shots you take are stored inside the app's own storage. They are not added to your photo library and contain no location information.
Subject details Names, reminder settings and lock settings, stored in the app's local database.
Places If you turn on a location reminder, the coordinates and radius of that one place are stored so iOS can tell the app when you are back there.
Settings Overlay opacity, auto shutter, tolerance and App Lock preferences.

None of this is sent to us or to anyone else. We could not access it even if we wanted to, because the app has no server.

Permissions the app asks for

Each one is requested only at the moment you use the feature that needs it, and the app keeps working if you say no.

Permission Why
Camera To take your photos and to compare the live view with your reference shot. Frames are analysed on the device and discarded immediately.
Location Only for location reminders. "Always" access is needed so iOS can notice your return while the app is closed. The app does not track or record where you go; iOS only reports the moment you enter a place you saved.
Notifications To deliver the reminders you set. They are local notifications created on your device, not push notifications from a server.
Face ID / Touch ID To unlock the app or a locked subject. Authentication is handled by iOS; the app only learns whether it succeeded and never sees your biometric data.
Add to Photos Only when you tap "Save to Photos" for a timelapse video you exported. The app cannot read your photo library.

Apple services the app uses

The only network services the app relies on are system services provided by Apple, which are governed by Apple's privacy policy: the App Store, for optional tips, and Apple Maps, which draws the map behind your subjects. Opening the map fetches map tiles for the area shown; the subjects themselves are never sent anywhere.

Tips

Optional tips are processed by Apple through the App Store. We never receive your payment details. Apple may share aggregated sales figures with us, which do not identify you. Tips do not unlock anything.

Widgets, Siri and Shortcuts

The widget reads a small summary (subject names and the dates of their last shots) that the app shares with it on your device. Shortcuts and Siri phrases use the subject names you entered. This is handled by iOS on your device.

Backups

If you use iCloud Backup or back up your device to a computer, iOS includes this app's data, such as your photos, in that backup, as it does for other apps. Those backups belong to your Apple account and are governed by Apple's privacy policy and your device settings. We have no access to them.

Deleting your data

  • Deleting a shot or a subject in the app removes its photos from the device immediately.
  • Deleting the app removes everything it stored.
  • Permissions can be changed at any time in the Settings app.

Children

The app collects no data from anyone, including children.

Changes to this policy

If a future version changes how data is handled, this page will be updated and the effective date above will change before that version is released.

Contact

Questions about this policy: takaakiyayoi@gmail.com


© 2026 Taka Yayoi

終わったら次が始まる。連鎖するタイマーアプリ「つぎつぎタイマー」をリリースしました

「湯を沸かす 5 分 → 麺を茹でる 8 分 → 蒸らす 2 分」。この 3 つを、スタート 1 回で順番に鳴らしてくれるタイマーが欲しかった。

そんな動機で作った iOS アプリ つぎつぎタイマー を App Store に公開しました。

📱 App Store でダウンロード (iOS 26.0 以降・無料・広告なし)

つぎつぎタイマー

つぎつぎタイマー

  • icot
  • ユーティリティ
  • 無料
apps.apple.com 実行中のチェーンには、いまのステップ名・残り時間・終了予定時刻が並びます

つぎつぎタイマーとは

複数のカウントダウンを「つなげて」走らせるタイマーです。ステップを並べて チェーン を作っておくと、A が終わったら B が自動で始まります。料理、勉強、トレーニングの「次は何分」を、いちいちセットし直さなくて済みます。

主な機能

  • 連鎖 — ステップを並べてチェーンを作り、スタートは 1 回だけ。各ステップの終わりにアラームが鳴り、次のステップが自動で始まります
  • ロック画面と Dynamic Island に常駐 — ステップ名 (「麺を茹でる」)、残り時間、終了予定時刻 (「終了 8:43」) を文字で表示します
  • 止めるまで鳴る — iOS 26 の AlarmKit で鳴らすので、サイレントモードや集中モード中でも鳴り、途中で切れません
  • 複数のチェーンを同時に — パスタとオーブン、ポモドーロと洗濯。同時実行数に上限は設けていません
  • 一時停止 / 再開 / スキップ / 中止 — チェーン単位で操作できます
  • 繰り返し — チェーン全体を n 回。「集中 25 分 → 休憩 5 分」を 4 周、のように使えます
  • クイックタイマー — 秒数を入れてすぐ始める単発タイマー
  • アラート音 — 4 種類 (ベル・チャイム・マリンバ・ビープ) をステップごとに選べます
  • 日本語・英語 に対応

なぜ作ったのか

きっかけは、パスタを茹でながらタイマーを 3 回セットし直していたことです。「沸いたら 8 分」「茹だったら 2 分」と、前のタイマーが鳴るたびに次を入力する。鳴った瞬間は手が離せないことも多く、次のセットが遅れて、そのぶん料理の時間もずれていきます。

複数タイマーのアプリは既にたくさんあります。ただ App Store のレビューを読んでいくと、同じ不満が繰り返し出てきました。

  • アップデートのたびに広告が大きくなる
  • ロック画面の表示がタイマー名から色のマークに変わって、どれがどれか分からなくなった
  • 残り時間は出るが、「何時に鳴るのか」が分からない
  • アラームの音が途中で切れる。止めるまで鳴らしてほしい
  • 同時に設定できる数が減った
  • 音楽を流しながら使うと、音楽が止まる
  • 1 画面目を消したら、2 画面目で走っていたタイマーまで消えた

これらを 最初から要件として固定して 作ったのがつぎつぎタイマーです。広告は出さない。ロック画面にはステップ名を文字で出す。終了予定時刻を必ず出す。止めるまで鳴らす。同時実行数に上限を設けない。他のアプリの音を止めない。削除は常に 1 件ずつで、走っているものを巻き込まない。実装の都合でこれらを削らない、と決めてから設計に入りました。

「何をしていて、何時に終わるか」が、ロック画面を見るだけで分かります

舞台裏: アプリが起動していなくても連鎖が進む

このアプリで一番考えたのは、アプリが終了していても、iPhone がロックされていても、チェーンが最後まで進む ことです。「次のステップを始める」処理をアプリが担っていると、バックグラウンドで止められた時点で連鎖が途切れます。

そこで、チェーンを開始した時点で 全ステップの開始時刻と終了時刻を先に計算し、まとめて iOS に登録する 方式にしました。

start[0] = いま
start[i] = start[i-1] + step[i-1] の長さ
終了[i]  = start[i] + step[i] の長さ

各ステップは AlarmKit の独立したアラームとして登録され、以降はアプリが一切関与しなくても iOS が順番に鳴らしてくれます。単一の基準時刻から絶対時刻を計算しているので、ステップを重ねても誤差が累積しません。実機で、アプリを強制終了した状態から 4 ステップのチェーンが最後まで自動で進むことを確認しています。

AlarmKit を採用した理由

iOS 26 で追加された AlarmKit は、サードパーティのアプリが「時計」アプリと同じ扱いのアラームを鳴らせる仕組みです。通知ではなくアラームなので、サイレントスイッチや集中モードを貫通し、全画面で鳴り続けます。「止めるまで鳴る」という要件は、この仕組みがあって初めて満たせました。

ロック画面と Dynamic Island の表示 (Live Activity) も AlarmKit が管理してくれるので、アプリが動いていなくても次のステップに切り替わったときに表示が入れ替わります。

一時停止と再開は、いったん全部取り消して登録し直す

AlarmKit にはアラーム単位の一時停止がありますが、それを使うと止まるのは「いま鳴ろうとしている 1 件」だけで、後続のステップは予定どおり進んでしまいます。チェーンとしての一時停止にするため、未発火のアラームをすべて取り消して各ステップの残り時間を保存し、再開時にその残り時間から全ステップを再計算して登録し直しています。スキップも同じ仕組みで、以降のステップを残り時間ぶん前倒しして再登録します。

音楽を止めない

タイマーアプリを開いた瞬間に音楽が止まる、というのは地味に嫌なものです。アプリ内の試聴を含めて、オーディオセッションは他のアプリの再生と混ざる設定 (.ambient + .mixWithOthers) にしています。アラート本体は AlarmKit が鳴らすので、こちらの設定に影響されません。

技術スタック

  • Swift 6 / SwiftUI — Xcode 26、strict concurrency
  • AlarmKit — アラームと Live Activity
  • SwiftData — チェーンの保存
  • StoreKit 2 — 応援 (投げ銭)
  • XcodeGen — プロジェクト定義を YAML で管理

外部ライブラリへの依存はゼロ。Apple 純正の技術だけで構成しています。

プライバシーと料金

このアプリは ネットワーク通信を行いません。作ったチェーンや設定は iPhone の中にだけ保存され、どこにも送られません。広告 SDK も解析 SDK も入れていません。

全機能無料で、機能制限や回数制限もありません。気に入っていただけたら、設定画面から開発者を応援できます (応援しても機能は変わりません)。

これから

v1 はまず「連鎖が確実に進む」ことに集中しました。この先、使いながら考えているのは次のあたりです。

  • ホーム画面ウィジェット — 実行中のチェーンをホーム画面からも
  • iCloud 同期 — 作ったチェーンを iPad と共有
  • Apple Watch — 手首で残り時間を確認して、次へ進む

「こういう使い方をしている」「ここが使いにくい」という声が一番の材料になります。ぜひ聞かせてください。

おわりに

タイマーは「セットする」道具でした。つぎつぎタイマーは、一度並べたら「あとは任せる」道具です。

鍋の前で、机の前で、次の 5 分を数え直さなくていい時間が少しでも増えたら嬉しいです。

📱 App Store でダウンロード

つぎつぎタイマー プライバシーポリシー / Privacy Policy

最終更新: 2026年9月12日

このアプリは一切の情報を収集しません。

データの収集と送信

つぎつぎタイマーはネットワーク通信を行いません。作成したチェーン (テンプレート)、実行中のタイマー、設定は、お使いの端末の中にだけ保存され、開発者や第三者に送信されることはありません。アカウント登録もありません。

広告とトラッキング

広告は表示しません。広告 SDK、解析 SDK、トラッキング技術は一切含まれていません。

アラームの許可について

タイマーの終了をお知らせするために、iOS の「アラームとタイマー」(AlarmKit) の許可を使用します。この許可はタイマーを鳴らす目的以外には使いません。

アプリ内課金 (応援)

「開発者を応援する」の購入は Apple の App Store を通じて処理されます。購入に関する情報は Apple のプライバシーポリシーに従って扱われ、このアプリが受け取るのは「購入が完了した」という結果だけです。支援しても機能は変わりません。

お子様のプライバシー

年齢を問わず、いかなる個人情報も収集しません。

変更

このポリシーを変更する場合は、このページを更新します。

お問い合わせ

ご質問はサポートページからお願いします。


Privacy Policy (English)

Last updated: September 12, 2026

This app collects no information at all.

Data collection and transmission

Tsugitsugi Chain Timer never connects to the network. Your chains (templates), running timers, and settings are stored only on your device and are never sent to the developer or to anyone else. There is no account.

Advertising and tracking

The app shows no ads and contains no advertising SDKs, analytics SDKs, or tracking technology.

Alarm permission

The app uses the iOS "Alarms & Timers" permission (AlarmKit) only to notify you when a timer ends.

In-app purchases (tips)

"Support the Developer" purchases are processed by Apple's App Store. Purchase information is handled under Apple's privacy policy; the app only learns that a purchase completed. Tips don't change any features.

Children's privacy

We collect no personal information from anyone, of any age.

Changes

If this policy changes, this page will be updated.

Contact

Questions are welcome via the support page.

分身が日本の鉄道を勝手に旅して日記を書くサービスをClaude Codeで作って公開した

はじめに

分身のキャラクターが日本の実在の鉄道路線を勝手に旅して、途中下車した駅の名物や車窓の風景を一人称の日記に書いて届けてくれる、というWebサービスを作って公開しました。

bunshin-tabi.com

企画からデータ処理、日記の生成、OGP画像、独自ドメインでの公開まで、ほぼすべてを Claude Code と対話しながら作りました。いわゆる「操作する旅」ではなく、旅かえるのように「分身が勝手にした旅の報告を受け取る」非同期の体験を狙っています。

技術的には、駅データ.jpのCSV、OpenStreetMapの実線形、Wikipediaの沿線情報、OpenAIの日記生成、Cloudflare Workersの静的配信、GitHub Actionsの夜間バッチ、といった無料〜低コストの部品を組み合わせただけです。ただ、実際に組み立てると「駅の座標はあるが線路の形がない」「LLMが日記を途中でやめる」「PWAのService Workerが全ページを真っ白にする」といった、地味だが必ずつまずくポイントがいくつもありました。そのあたりも含めて、作った順番で書いていきます。

分身の旅日記とは

コンセプトはシンプルです。

  • 分身(名前・絵文字・性格を持ったキャラ)が、日本の実在の鉄道路線を1本旅する
  • 途中下車した駅では長めの日記、通過する駅では車窓からの短い一言を書く
  • 沿線の実在スポット(名物・史跡・河川・橋梁・廃線跡など)を固有名詞で織り込む
  • 毎朝1本、全ユーザーが同じ「便」を読む新聞連載のような形で配信する
  • 旅した路線は日本地図に蓄積され、どこまで旅したかが一目で分かる

ポイントは「実在」であることです。架空の路線ではなく、駅の座標も線路の形も実データを使う。日記に出てくる橋や川や名物も、Wikipediaに載っている本物を使う。この具体性があると、単なる自動生成テキストが「どこか本当にありそうな旅」に見えてきます。

同じ路線でも、性格の違う分身が旅すると「何に目を留めるか」が変わって別の旅になります。廃線と橋梁が好きな無口な猫(絵文字は🐈‍⬛)と、食べ物と古い街並みに弱いのんびり屋では、同じ駅を通っても書く内容がまるで違う。ここが生成AIらしい面白さでした。

実際の画面はこんな見た目です。左に地図と分身のマーカー、右に日記のフィードが並び、「旅を再生」すると分身が線形の上を動きながら、日記が時系列で現れます。カードをクリックすると地図がその地点にジャンプします。

全体像: 認証もDBもない静的サイト

v1では、あえて認証もデータベースも持たせませんでした。全員が同じ便を読むので、ユーザーごとの状態が要らない。だから構成は次のように振り切れます。

  • 配信: trips/<日付>.json を静的ファイルとして置くだけ
  • 生成: 夜間バッチを1本回して、翌朝の便のJSONを作る

データの流れはこうです。

  1. 路線を選ぶ(日付をシードに決定的に選定)
  2. その路線の実線形をOpenStreetMapから取得(路線ごとに一度取ればキャッシュできる)
  3. 沿線スポットをWikipediaから集める
  4. ペルソナ + 路線 + スポットをプロンプトに渡してOpenAIで日記を生成
  5. trip JSONに保存して静的配信

配信はCloudflare Workers(静的アセット)、夜間バッチはGitHub Actionsのcronにしました。どちらも無料枠で収まります。ランニングコストで意味があるのは、実質OpenAIのAPI呼び出し(1便あたり1回)だけです。

駅はあるが線路がない: 実線形をどう取るか

最初のハマりどころがここでした。駅データ.jpの無料CSVには全国約1万駅の座標が入っていて、路線ごとに駅を並べることはできます。ただし、駅と駅を結ぶ「線路の形」は入っていません。駅を直線でつなぐと、カーブの多いローカル線はまるで別物になってしまいます。

そこでOpenStreetMapのOverpass APIから実際の線路の形を取ります。路線名で問い合わせるのですが、これが素直にいきません。

  • route relationがある路線とない路線がある(名松線にはなかった)
  • OSM上の路線名は表記ゆれがある(「南海電気鉄道加太線」のように事業者名付きだったり)
  • 他の路線と線路を共有する区間がある(加太線の和歌山市〜紀ノ川は南海本線を走る)

なので、relationを試してダメならway検索にフォールバックし、路線名は正規表現の部分一致で拾います。

[out:json][timeout:60];
relation["route"="train"]["name"~"加太線"];
out geom;

共用区間が厄介でした。路線名だけで引くと、共有している区間の線路が欠けて駅がつながりません。ここは、取得したway群の全頂点をノードとするグラフを作り、起点駅から終点駅までをダイクストラ法で最短経路探索する方式にしました。bbox指定で周辺の全路線の線路を集め、最大連結成分の中で起点・終点に最も近いノードを選んでから探索します。

取れた線形は、そのままだと点が多すぎるのでDouglas-Peuckerで200点前後に間引きます。環状線は始点=終点で退化するので、2分割してから間引く必要がありました。

正しく取れたかは、生成AIには任せず機械的に検証します。

  • 各駅を線形上に垂線射影して弧長を求め、駅の並び順で弧長が単調増加すること
  • 駅と線形の距離が数十m以内であること
  • 総延長が実路線長とほぼ一致すること

この3点で自動判定させました。実際に加太線を通したところ、共用区間を含めて12.1kmが得られ、実路線の12.0kmとほぼ一致しました。山手線34.5km、名松線43.5kmも実測とほぼ合致。線形の検証を機械化しておくと、路線を増やしたときに「なんか変な形になっている」を人手で見なくて済みます。

新幹線・地下鉄・ケーブルカーなどは旅の対象から外し、最終的に旅できる路線は537本になりました。

沿線スポットを集めて日記を書かせる

線形が取れたら、次は日記の材料です。ここの質で日記の面白さが決まります。定型文っぽくなると一気に冷めるので、実在の固有名詞をどれだけ織り込めるかが勝負でした。

材料はWikipediaのMediaWiki APIから、路線記事と各駅記事のイントロを抜き出して集めます。これをペルソナ定義(名前・性格・興味の対象・文体)と一緒にプロンプトへ渡し、OpenAIに日記の配列を書かせます。

出力の形は、構造化出力(response_format の json_schema strictモード)で固定しました。各エントリは時刻・駅インデックス・種別(stop/pass)・本文HTMLという形です。

const body = {
  model: "gpt-4o-mini",
  messages: [
    { role: "system", content: system },
    { role: "user", content: user },
  ],
  response_format: {
    type: "json_schema",
    json_schema: { name: "diary", strict: true, schema: DIARY_SCHEMA },
  },
  max_completion_tokens: 12000,
};

これで、パースに失敗しない安定したJSONが返ってきます。固有名詞にはWikipediaや検索へのaタグを本文に埋め込ませ、「事実の捏造をしない。自信がないスポットは書かない」というルールもプロンプトで縛りました。

日記が途中の駅で止まる

公開後、7駅ある路線なのに日記が2駅目で終わっている便を見つけました。終点まで旅せずに、途中でぷつっと切れている。gpt-4o-miniが「全駅をたどって終点まで書く」という指示を守り切れず、短い出力で満足してしまったのが原因でした。

ここが生成AIを組み込むときの典型的なハマりどころだと思います。プロンプトで指示しても、モデルが弱いと平気で途中でやめる。そこで、プロンプトを強くするだけでなく、生成結果を検証して未達ならリトライする仕組みを足しました。

function isComplete(diary, stationCount) {
  if (!diary || diary.length < Math.max(3, Math.ceil(stationCount * 0.6))) return false;
  const maxSt = Math.max(...diary.map((e) => e.st));
  if (maxSt < stationCount - 1) return false; // 終点に到達していない
  if (!diary.some((e) => e.st === 0)) return false; // 起点がない
  return true;
}

終点に到達しているか、駅数に対して十分な件数か、起点があるかを見て、満たさなければ最大3回まで、より強い指示を添えて作り直します。プロンプトだけに頼らず、出力を機械で検証して駄目なら回す。この一手間で、モデルの気まぐれに振り回されにくくなりました。

品質をもう一段上げたいときは、OPENAI_MODEL を gpt-4o に切り替えれば指示追従が明確に良くなります。ここはコストとの相談で、環境変数一つで動かせるようにしてあります。

SNSで映えるOGP画像を動的に作る

拡散はSNS共有が頼りなので、リンクを貼ったときのカードにこだわりました。便ごとに、その路線の実際の形をそのまま絵にしたOGP画像(1200×630のPNG)を生成しています。

ここで二つ、地味だが重要な壁がありました。

一つ目は、SNSのクローラはJavaScriptを実行せずにHTMLの <head> しか読まない、という点です。SPAのままだと全ページ同じOGPになってしまう。なので、ビルド時に便ごとの実HTMLを生成し、og:title や og:image を焼き込んでおく必要があります。

二つ目は、画像内に日本語を描くために日本語フォントが要る、という点です。PNG化には @resvg/resvg-wasm を使いました。ネイティブ依存がなく、Cloudflareのビルド環境でもそのまま動くのが決め手です。

import { Resvg, initWasm } from "@resvg/resvg-wasm";

await initWasm(readFileSync("node_modules/@resvg/resvg-wasm/index_bg.wasm"));
const r = new Resvg(svg, {
  font: { fontBuffers: [fontBuffer], defaultFontFamily: "Noto Sans JP", loadSystemFonts: false },
  fitTo: { mode: "width", value: 1200 },
});
writeFileSync(out, r.render().asPng());

ここでもう一つつまずきました。Noto Sans JPのバリアブルフォントをそのまま渡すと、SVGで font-weight: 700 を指定しても細いウェイトで描画されてしまう。文字が細くて薄い、と指摘を受けて気づきました。resvgがバリアブルフォントのウェイト軸を効かせてくれないようです。

対処は、fonttoolsでバリアブルフォントを太字ウェイトに固定した静的インスタンスを作り、それを渡すことでした。

python3 -m fontTools.varLib.instancer "NotoSansJP[wght].ttf" wght=700 -o NotoSansJP-Bold.ttf

これで路線名も駅名もしっかり太字で描画されるようになりました。実際に生成されるカードはこんな見た目です。山手線ならあのループの形が、そのまま画像になります。

生成されたOGPカード。路線の実際の形と、路線名・区間・距離が入っている

画像生成は依存インストールが絡んで壊れやすいので、動的importとtry/catchで囲み、失敗してもサイトのビルド自体は止まらないようにしてあります。

全ページが真っ白: Service Workerの罠

公開して少し経ってから、「他の旅を選んでも表示されない」「リンクを開くとERR_FAILED」という報告が来ました。トップから個別の旅に遷移すると、ページが真っ白になる。

原因はPWA用に入れていたService Workerでした。ナビゲーション時にキャッシュした /index.html を返す実装だったのですが、Cloudflareの静的アセット配信では /index.html がリダイレクト扱いになるため、それをキャッシュから返すと空のページになってしまう。初回の直リンクだけはService Workerが制御する前なので動き、以降のナビゲーションが壊れる、という分かりにくい壊れ方でした。

ここは判断として、v1ではオフライン機能よりも確実性を優先し、Service Workerを廃止しました。ただ、すでに壊れたSWを持ってしまったブラウザを放置できないので、新しいSWを「キルスイッチ」にして、起動時に全キャッシュを削除して自身をunregisterし、開いているタブを再読込させるようにしました。

self.addEventListener("activate", (event) => {
  event.waitUntil((async () => {
    const keys = await caches.keys();
    await Promise.all(keys.map((k) => caches.delete(k)));
    await self.registration.unregister();
    const clients = await self.clients.matchAll({ type: "window" });
    for (const client of clients) client.navigate(client.url);
  })());
});

これをデプロイすると、壊れたSWを持つブラウザも次のアクセスで自動的に回復しました。よかれと思って入れたキャッシュ機構が体験を壊す、というのはPWAでありがちな事故だと思います。オフライン対応が必須でないなら、無理に入れない方が安全でした。

トップは「日本が埋まっていく」地図に

便が増えていく蓄積型のサービスなので、トップページには全国カバレッジマップを置きました。これまで旅した全路線を日本地図にプロットし、便が増えるほど日本が線で埋まっていくのが見えるようにしています。

最初は路線の線を赤系にしていたのですが、OpenStreetMapの地図は道路も鉄道も赤やピンクなので紛れてしまう。濃い紫にして、さらに白いふち(ケーシング)を敷くことで、背景に関係なく浮いて見えるようにしました。地図上に自前の線を重ねるときの定番テクニックです。

また、地図と統計だけだと「これが何のサイトか」が初見では伝わらないので、コンセプトの説明文と、その日の日記の冒頭を引用する「今朝の一節」を足しました。読む前の味見があると、地図だけでは出せない読み物としての引力が出ます。

公開: Cloudflare Workers + 独自ドメイン

配信はCloudflare Workersの静的アセットにしました。wrangler.jsonc で not_found_handling を single-page-application にしておくと、実ファイルのないパスには index.html を返してくれます。便ごとの実HTMLは実ファイルとして置いてあるので、そちらが優先され、OGPメタも正しく配信されます。

GitHub Actionsの夜間バッチは、毎日日本時間の朝4時に翌朝の便を生成してコミットし、pushをトリガーにCloudflareが自動で再デプロイします。生成に必要なOpenAIキーはリポジトリのSecretに置くので、手元に鍵がなくても運用が回ります。

最後に独自ドメイン bunshin-tabi.com を取りました。OGP画像や共有リンクのURLは環境変数 SITE_URL 一つで切り替わるようにしてあったので、ドメインを繋いでからその値を差し替えるだけで、カードもリンクも一斉に新ドメインへ移りました。

法務面も先に確認しています。駅データ.jpは商用利用・加工利用ともにOK、OpenStreetMapはODbLで「© OpenStreetMap contributors」の表記を維持、地理院タイルは出典表記を維持。これらはサイトのクレジット表記で明示しています。

まとめ

分身の旅日記をゼロから作って公開してみて分かったことをまとめます。

  • 駅の座標はオープンデータで手に入るが、線路の形はOSMから別途取る必要がある。共用区間はグラフ + ダイクストラで解くのが確実
  • 線形が正しいかは生成AIに判断させず、弧長の単調増加・駅との距離・総延長で機械的に検証すると、路線を増やしても破綻に気づける
  • LLMは指示しても途中で仕事をやめることがある。出力を検証して未達ならリトライする仕組みを一枚かませると安定する
  • OGPは、SNSクローラがJSを読まないので便ごとの実HTMLにメタを焼き込む必要がある。画像内の日本語はバリアブルフォントを静的ウェイトに固定してから描く
  • PWAのService Workerは、よかれと思って入れると全ページを壊すことがある。オフラインが必須でないなら無理に入れない
  • 静的配信 + 夜間バッチ + LLM 1コールという構成なら、認証もDBもなしで、ほぼ無料枠で個人サービスとして公開まで持っていける

一番の収穫は、生成AIを組み込むほど「AIに任せない部分」の設計が効いてくる、と実感できたことでした。線形の検証も、日記の完全性チェックも、やっているのはただの機械的な判定です。生成の面白さは残しつつ、破綻しうるところは古典的なコードで押さえる。この役割分担がうまくいくと、毎朝ひとりでに旅が増えていくサービスが、手を離しても回るようになりました。

まだ数便しかありませんが、蓄積型のサービスなので本番は数週間後です。日本地図が線で埋まっていくのを、のんびり眺めようと思います。

参考リンク

単価比較アプリ「どっち安い?」を公開しました - 入れてから外した機能の記録

はじめに

iOSアプリ「どっち安い?」をApp Storeで公開しました。

どっち安い? - グラム単価計算

どっち安い? - グラム単価計算

  • icot
  • ショッピング
  • 無料
apps.apple.com

スーパーの棚の前で「500gで398円」と「1kgで680円」のどっちが安いかを、数タップで判定するアプリです。無料、広告なし、iOS 17.0以降。

機能を足していった記録ではなく、一度入れてから外したものと、中心機能を2回作り直した話を書きます。個人開発だと足す話ばかりが記事になりますが、実際に効いたのは削る判断のほうでした。

どっち安い? とは

やることは2つだけです。

価格と内容量を入れると、100gあたりの値段に揃えて比べます。安い方に印が付いて、何%お得かが出ます。g / kg / ml / L / 個 / 枚 / 回分に対応していて、「500g×3袋」のようなまとめ買いもそのまま計算できます。

もう1つが「いつもの単価」です。よく買うものは、そのときの値段と内容量を覚えておけます。次にその商品を選ぶと覚えた数字が入力欄に入るので、目の前の値段を打つだけで「いつもより安いか」が分かります。

技術構成はSwiftUI + SwiftData、プロジェクト生成はXcodeGenです。通信コードは1行もなく、データは端末内だけに保存しています。

入れてから外したもの

税抜/税込の切り替え

最初は税抜入力に対応していました。軽減税率8%と標準税率10%を選べて、1円未満を切り捨てて税込に直す実装です。テストも書きました。

外しました。理由は単純で、単価比較の答えを変えないからです。

同じカテゴリの2商品なら税率も同じです。両方に同じ率を掛けても「どっちが安いか」も「何%お得か」も変わりません。変わるのは絶対額の表示だけ。

しかも総額表示義務があるので、値札には必ず税込価格が載っています。税抜を大きく書く店でも税込は併記されていて、書き方は店内で揃っている。つまりあのトグルは「同じ値札に並んでいる2つの数字の、どっちを打つか」でしかありませんでした。

店頭で1タップでも減らしたいアプリで、答えに影響しないことをユーザーに考えさせていたわけです。

おまけに副作用もありました。「いつもの単価」は先月税抜で打って今月税込で打つと基準がずれます。入り口が1つなら起きない問題を、自分で作っていました。

そして8%/10%は、法改正でコードが間違いになる唯一の箇所でもありました。

ウィジェット3つ

ロック画面ランチャー、ホーム画面の小と中、合わせて3つ作りました。中サイズは「いつもの単価」を上位3件出して、行をタップするとその商品を選んだ状態で比較画面が開く、という作りです。

これも外しました。きっかけは「周回の買い物なのに、肉売り場に直行するの?」という指摘でした。

ウィジェットのタップが効くのは、ポケットから出す前に「今から何を見るか」が決まっているときだけです。でも実際の買い物は店内を回るもので、目の前に商品が現れてから「これ安い?」と思う。順番が逆でした。

中サイズに出るのは上位3件なので、メモが5件あれば目当てが載っている保証もありません。結局アプリを開いて選び直すことになります。

外して分かったのは、コストのほうが大きかったことです。

  • ウィジェットのためにApp Groupが必要で、それが署名付きArchiveを止めていた唯一の原因だった
  • Xcode 26.6のシミュレータではウィジェットギャラリーが全アプリで空になる環境問題があり、一度も描画を確認できていなかった

つまり、未検証のコードのために提出がブロックされていました。削除したら署名付きArchiveがそのまま通るようになりました。

"SigningIdentity" => "Apple Development: Takaaki Yayoi (N925JXBD3G)"

2回やり直した「いつもの単価」

このアプリの差別化はここだけなので、いちばん時間をかけました。そして2回やらかしました。どちらも原因は同じで、枠が2種類になっていたことです。

1回目: 1つの枠に2つの意味を入れた

最初の実装は、メモを枠に「貼り付ける」形でした。1枠目にメモを結びつけて、同じ1枠目に目の前の商品の値段も打つ。単価の下に「いつもより12%安い」と出す作りです。

実機で使ってみると、どこに何を打つのか分かりませんでした。1つの枠に「覚えている商品」と「目の前の商品」が同居していたからです。

さらに悪いことに、画面上部のバナーは「もう1つ入れると比べられます」と表示していました。メモとの比較はもう成立しているのに、いちばん目立つ場所が「まだ比べられない」と言っている。矛盾していました。

2回目: 入力欄のない枠を作った

次に考えたのが「メモが枠を占める」案です。1枠目がメモそのものになって、2枠目に目の前の商品を打つ。

これも却下されました。「入力欄のない枠を作ったら、結局それも普通の枠とは別物じゃないか」と。そのとおりです。見た目も振る舞いも違う枠が2種類できるだけで、1回目と同じ問題でした。

3回目: 枠は1種類、メモは数字を入れるだけ

最終的にこうなりました。

メモを選ぶと、その枠の入力欄に前回の数字がそのまま入る。それだけ。

① [にく] 398円 500g g ×1   ← メモを選ぶと数字が入る。普通の枠
②     450円 500g g ×1   ← 目の前の商品を打つ
        ↓
   1つ目が安い / 12%お得

枠は1種類しかありません。メモは「入力を代わりに打ってくれるショートカット」であって、それ以上の意味を持たせない。あとは普通の枠が2つ並んでいるだけなので、普通の最安判定がそのまま答えになります。

この形にした結果、消えたものがあります。

  • メモ専用のバナー表示
  • カード内の「いつもより○%安い」判定行と、そのための判定ロジック一式

「1つ目が安い / 12%お得」が同じことを言っているので、二重でした。設計が正しくなると、書いたコードが減ります。

データモデルも変わりました。当初は正規化済みの単価(100gあたり79.6円)を保存していましたが、値札の数字(価格・内容量・単位・個数)を保存して単価は毎回計算する形に変えました。副作用として、メモ一覧が「398円 / 500g → 100gあたり」と覚えている中身をそのまま見せられるようになり、編集も単価ではなく値段を直す形になって打ちやすくなりました。

UIで踏んだ失敗

押した先が行き止まりだった

枠にはメモ呼び出し用のボタンがあります。メモが1件もない状態でこれを押すと、「メモがまだありません」という押せない文字が1行出るだけでした。

そこから何をすればいいのか、どこにも書いていません。完全な行き止まりです。

直したあとは、メモが無くても必ず次の行動が1つは出るようにしました。価格が入っていれば「この単価を覚える…」、何も入っていなければ「価格と内容量を入れると、この商品を覚えられます」。

ついでに、この修正で元の設計の穴にも気づきました。「覚える」ボタンは画面下に1つしかなく、いちばん安い枠しか覚えられなかったのです。2つ目の商品を覚えたくてもできませんでした。

アイコンだけのボタンを作った

メモの呼び出し口は、薄いグレーのブックマークアイコンでした。ラベルなし、メニューが開くことを示す記号もなし。

「ここから呼び出せるとは分からない」と指摘されました。そのとおりで、アプリの差別化機能の入口を、いちばん伝わらない形で置いていました。

文字を付けて解決しました。

  • 未選択: 🔖 いつもの ⌄
  • 選択済み: 🍖 鶏むね肉 ⌄

スクロールする中身にボタンを置いた

「覚える」ボタンをスクロール領域の中に置いていました。中身の量で位置が動くので、条件が揃うと画面外に出ます。

これは2回直しました。1回目は「案内文を2行から1行にする」で対処したのですが、不十分でした。

原因は検証の甘さです。確認したのが1枠だけ入力した状態で、結果バナーが低いときだったのです。バナーは状態で高さが変わります。

状態 バナーの高さ
未入力 低い
1枠だけ 中
結果あり 高い

実際にいちばんよく見る「結果あり」の状態を見ていませんでした。

2回目は構造から直しました。操作するものは固定、読むものはスクロール。「覚える」「枠を追加」をテンキーと同じ固定領域に移したので、枠を4つに増やしてもバナーが伸びても影響を受けません。

再発防止に、枠4つの最悪ケースを起動引数だけで再現できるようにしました。

xcrun simctl launch $D com.icot.unitprice -screenshot-seed -screenshot-scene crowded

BUILD SUCCEEDED は変更が入った証拠にならない

これが今回いちばん時間を溶かしました。

呼び出しチップに文字を足したのに、シミュレータで見ると見た目が変わりません。ソースを何度読んでも修正は入っています。ビルドは成功しています。インストールし直しても変わりません。

「レイアウトが崩れて文字が潰れているのでは」と考えて、ヘッダーの横幅を計算したり、layoutPriority を検討したり、しばらく別方向を掘りました。

原因は単純で、xcodebuild がそのファイルを再コンパイルしていなかっただけでした。touch して再ビルドしたら、すんなり反映されました。

$ xcodebuild ... build | grep -E "SlotCardView|\*\*"
** BUILD SUCCEEDED **          # ← コンパイル行が無い

$ touch UnitPrice/Views/SlotCardView.swift
$ xcodebuild ... build | grep -E "SlotCardView|\*\*"
SwiftCompile normal arm64 Compiling\ SlotCardView.swift ...   # ← 今度は走った
** BUILD SUCCEEDED **

** BUILD SUCCEEDED ** は「ビルドが成功した」としか言っていません。自分の変更が入ったかどうかは何も保証していない。

UIを直したのに見た目が変わらないときは、レイアウトを疑う前にビルドログに SwiftCompile 対象ファイル名 が出ているかを見る。これを飛ばしたせいで、存在しないレイアウトバグを何往復も追いかけました。

まとめ

公開までに分かったことをまとめます。

  • 単価比較では税率が答えを変えない。同じ率が両方に掛かるので、順位も差率も不変。変わるのは絶対額の表示だけ
  • ウィジェットは、ポケットから出す前に何を見るか決まっているときしか効かない。周回の買い物では順番が逆
  • 中心機能は2回作り直した。原因はどちらも枠が2種類あること。1種類に統一したら、専用の判定表示もロジックも丸ごと不要になった
  • 導出値を保存しない。単価ではなく値札の数字を保存して毎回計算する形にしたら、表示も編集も素直になった
  • アイコンだけのボタンは、何ができるか伝わらない。押した先が行き止まりのメニューも作らない
  • 操作するものは固定、読むものはスクロール。レイアウトの確認は「いちばん背が高くなる状態」でやる
  • 「BUILD SUCCEEDED」は変更が入った証拠にならない。ビルドログにコンパイル行が出ているかを見る

一番の収穫は、指摘を受けて削った判断がどれも正解だったことでした。税率もウィジェットも、自分では「せっかく作ったから」と残す方向に理屈をこねていました。作った本人は足したものを守りたくなるので、「これ要るの?」と聞いてくれる相手がいるかどうかが、個人開発では効くのだと思います。

削った結果、提出をブロックしていた要因も消えました。皮肉ですが、いちばん厄介だった問題は、いちばん要らなかった機能が持ち込んでいたものでした。

参考リンク