個人開発している Web アプリ「やめた理由」に、見直しの記録という機能を追加しました。
アプリを公開した後に、保存するデータの形を変えるのは初めてでした。この記事では、機能の設計で考えたことと、すでに使っている人のデータやバックアップファイルを壊さずに更新するために工夫したことをまとめます。
アプリの紹介と、最初の開発の流れは次の記事にまとめています。
追加した機能:「この判断は今も同じ?」を残す
「やめた理由」は、一度検討してやめたことについて、やめた理由と再検討の条件を記録するアプリです。
これまでは、状態(検討中・保留・見送り・採用)を変えたときだけ、判断の履歴が残る仕組みでした。そのため、記録を読み返して「やっぱり今もやらない」と確認しても、その確認はどこにも残りませんでした。いつ見直したのか分からず、見直すきっかけもありません。
そこで、次の機能を追加しました。
- 記録の詳細画面に「見直す」ボタンを追加。やめた理由と再検討の条件を読みながら、「今も同じ判断として記録」を押せる
- 見直しは、状態を変えずに判断の履歴へ「見直し(見送りのまま)」として残る。ひとことメモも付けられる
- 見送り・保留の記録で、半年以上見直していないものがあると、一覧画面に控えめな案内を出す
- 「見直し時期の記録だけ」のボタンで、そうした記録だけを表示できる




設計で考えたこと
今回も、先に設計書へ仕様を書いてから、AI のコーディングアシスタント(Claude Code)と実装しました。主な判断は次の4つです。
1. 見直しは「判断の履歴」に入れる
見直しを、別の保存先(IndexedDB のオブジェクトストア)に分けることもできました。ですが、詳細画面では「いつ、どう判断したか」を1本の時系列で読めるほうが分かりやすいと考え、既存の判断の履歴に種類(kind)を足すことにしました。
| kind | 意味 | 保存する内容 |
|---|---|---|
decision | 状態を決めた・変えた(これまでの履歴) | 新しい状態と、判断の理由 |
review | 状態を変えずに見直した(今回追加) | その時点の状態と、ひとことメモ |
2. 「最後に見直した日」は保存しない
一覧で「半年以上見直していないか」を判定するには、記録ごとの最後に見直した日が必要です。これを記録本体に保存すると、同じ情報を2か所に持つことになり、ずれる原因になります。
そこで、判断の履歴のうち一番新しい日付を、その都度計算することにしました。個人の記録なので件数は多くても数千件で、全件を読んで計算しても問題ありません。
export function lastReviewedAt(
history: readonly Pick<DecisionHistoryEntry, 'created_at'>[],
fallback: string,
): string {
// バックアップから復元した日時は toISOString() 形式とは限らないため、文字列ではなく時刻で比べる
return history.reduce(
(latest, entry) => (Date.parse(entry.created_at) > Date.parse(latest) ? entry.created_at : latest),
fallback,
)
}日付は ISO 8601 形式の文字列で保存しているので、文字列のまま比べても順番はおおむね正しくなります。ただ、バックアップファイルから読み込んだ日付は +09:00 のような別の書き方の可能性もあるため、時刻に直してから比べています。
3. 見直しても「更新日」は変えない
見直しを記録したときに、記録の更新日を変えるかどうかも迷いました。一覧は更新日の新しい順に並んでいるので、更新日を変えると、見直した記録が一番上に来ます。
今回は、更新日は「内容か状態を変えた日」と決めて、見直しでは変えないことにしました。見直しただけで一覧の順番が入れ替わると、かえって探しにくくなると考えたからです。
4. 通知は使わず、期間は固定にする
「半年たったら通知する」という方法もありますが、このアプリは判断を急かさないことを大事にしています。通知は使わず、アプリを開いたときに一覧の上へ1行だけ案内を出すことにしました。件数を赤く目立たせることもしません。
対象は見送り・保留の記録だけです。採用したものや、まだ検討中のものは「やめた理由が今も有効か」を確かめる対象ではないからです。期間は180日に固定し、設定画面は作っていません。
公開中のデータを壊さずに形を変える
ここからが今回の本題です。アプリはすでに公開していて、利用者の端末の IndexedDB にはデータが入っています。そこに kind という項目を増やす必要がありました。
IndexedDB の移行(v4 → v5)
前回の記事で書いたとおり、IndexedDB のデータの形を変える処理(マイグレーション)は、過去の処理を書き換えず、末尾に追加するだけにしています。今回は v5 として、既存の履歴すべてに kind: 'decision' を補う処理を追加しました。
// v5: 判断履歴の種類(判断 / 見直し)。既存の履歴はすべて判断として補完する
async (_db, transaction) => {
let cursor = await transaction.objectStore('decision_history').openCursor()
while (cursor) {
// v4 までの履歴には kind がない(型上は必須だが、保存済みのデータにはない)
if (cursor.value.kind === undefined) {
await cursor.update({ ...cursor.value, kind: 'decision' })
}
cursor = await cursor.continue()
}
},この処理は、DB のバージョンを上げるための特別なトランザクションの中で動きます。このトランザクションは、IndexedDB 以外の処理(fetch など)を await すると終わってしまうので、カーソルで1件ずつ読み書きするだけにしています。
「kind がなければ補う」ではなく「すべての履歴に必ず kind がある」状態にしておくと、アプリ側のコードでは kind を必須の項目として扱えます。「kind がない場合は判断とみなす」という分岐を、あちこちに書かずに済みます。
TypeScript の型と、過去の移行処理の関係
ここで1つ悩みました。kind を必須にした結果、過去の v3 の移行処理が型エラーになったのです。v3 では、当時の形(kind のない履歴)でデータを書き込んでいるからです。
過去の移行処理は書き換えないルールなので、処理の中身はそのままにして、型の注釈だけを足しました。v3 の時点では kind がなく、v5 で補う、という経緯をコメントに残しています。型は「今のデータの形」を表すもので、過去の移行処理は「当時のデータの形」で書かれている、というずれに気付けたのは収穫でした。
バックアップファイルの形式もバージョンを上げる
このアプリには、記録を JSON ファイルに保存して、別の端末で復元する機能があります。ファイルには format_version という番号を入れていて、DB のバージョンとは別に管理しています。
今回、履歴に kind が加わったので、ファイルの形式も version 2 に上げました。
| ファイル | 新しいアプリで読み込むと |
|---|---|
| version 1(これまでのファイル) | kind がないので、すべて「判断」として読み込む |
| version 2(これから作るファイル) | kind が「判断」か「見直し」かを確認して読み込む |
/** formatVersion 1 のファイルには kind がないため、すべて判断(decision)として読む。 */
function readHistory(item, report, formatVersion) {
const kind = formatVersion === 1 ? 'decision' : item.kind
if (!isHistoryKind(kind)) return report('種類(判断・見直し)の値が正しくありません。')
// …(ほかの項目の検証)
}逆に、古いアプリで version 2 のファイルを読み込んだ場合も考える必要があります。古いアプリは知らない項目を無視するので、そのまま読み込むと、見直しが「判断」に化けてしまいます。これを防ぐため、最初の版から「自分より新しい形式のファイルは読み込まず、アプリの更新を案内する」ようにしていました。今回、その備えがそのまま役に立ちました。
古いデータを再現してテストする
移行処理は、一度公開すると取り消せません。そこで、v4 の DB を再現して、v5 に移行するテストを書きました。fake-indexeddb を使うと、Node.js 上で IndexedDB をそのまま扱えます。
it('v4 の判断履歴に種類(判断)を補完し、既存の履歴を欠かさない', async () => {
// v4 時点のデータベースを再現する
const v4 = await openDB('yameta-riyu', 4, {
upgrade(db) {
// …(v4 時点のストアとインデックスを作成)
},
})
await v4.put('decision_history', {
id: 'h1', consideration_id: 'old-1', status: 'rejected', reason: '当時の理由', created_at: T,
})
v4.close()
// アプリの通常の読み込みで v5 への移行が走る
const detail = await repo.getConsiderationDetail('old-1')
expect(detail?.history[0].kind).toBe('decision')
})このほか、「見直しても状態・本文・更新日が変わらない」「記録を削除すると見直しの履歴も消える」「version 1・2 のバックアップをどちらも読み込める」などのテストを追加し、自動テストは83件になりました。
開発中の PC のブラウザにも v4 のデータが残っていたので、開発サーバーを開くだけで、実際のデータでも移行を確かめられました。
公開前の確認でハマったこと
1. プレビュー用の URL が本番に転送されてしまう
Cloudflare Pages は、main 以外のブランチを push すると、本番とは別の URL(ブランチ名.プロジェクト名.pages.dev)にプレビュー版を公開してくれます。今回の変更は本番の DB の形を変えるので、公開前にプレビューで確認するつもりでした。
ところが、プレビューの URL を開くと、本番のサイトに転送されてしまいました。
原因は、前回の公開時に設定した転送ルールです。プロジェクト名.pages.dev で記録してしまう人が出ないよう、Cloudflare の一括リダイレクト(Bulk Redirects)で本番の URL へ転送していました。この設定で「サブドメインを含める」をオンにしていたため、ブランチ名.プロジェクト名.pages.dev まで転送の対象になっていたのです。
「サブドメインを含める」をオフにすると、プロジェクト名.pages.dev は転送されたまま、プレビューの URL は表示されるようになりました。
curl -sI https://プロジェクト名.pages.dev/ # 301(本番へ転送)
curl -sI https://ブランチ名.プロジェクト名.pages.dev/ # 200(プレビューを表示)プレビューは本番と URL(オリジン)が違うので、IndexedDB も Service Worker も本番とは別です。プレビューで記録を作っても、本番の利用者のデータには影響しません。
2. 公開版と手元のビルドが「違う」と判定される
公開後には、公開中のファイルと手元のビルド結果を比べるスクリプトを動かしています。今回、Service Worker のファイル(sw.js)だけが「違う」と判定されました。
差分を調べると、違っていたのは使い方ガイドのページの、更新確認用のハッシュ値だけでした。原因は改行コードです。Windows の作業フォルダでは Git がファイルを CRLF に変換していましたが、Cloudflare Pages は Linux 上で LF のままビルドします。中身は同じでも、ハッシュ値が変わっていました。
.gitattributes に次の1行を追加し、作業フォルダでも LF にそろえることで解消しました。
* text=auto eol=lf公開版に問題はなかったものの、確認用のスクリプトが毎回 ✗ を出すと、本当の問題を見落とす原因になります。早めに直せてよかったです。
公開するときに気を付けたこと
この版を公開すると、利用者がアプリを更新した時点で、端末の DB が v5 になります。v5 の DB は古い版のアプリでは開けないので、公開後に古い版へ戻すことはできません。
そのため、不具合が見つかっても古い版には戻さず、修正版を公開して対応する方針にしました。そのうえで、公開前に次の確認をしました。
- 自動テスト・型チェック・ビルド
- ローカルで本番用のビルドを動かし、PC の Chrome で一通り操作する(サーバーを止めてオフラインでも確認)
- プレビューの URL で、本番と同じ配信環境(CSP などのヘッダーを含む)で動かす
まとめ
- 「やめた理由」に、状態を変えずに「今も同じ判断」を残せる見直し機能を追加した
- 見直しは判断の履歴に種類を足して記録し、「最後に見直した日」は保存せずに計算する
- 公開中の IndexedDB は、移行処理を末尾に追加して、既存のデータに新しい項目を補う
- バックアップファイルにも形式のバージョンを持たせておくと、データの形を変えたときに古いファイルを変換して読める
- Cloudflare Pages の一括リダイレクトで「サブドメインを含める」をオンにすると、プレビューまで転送される
公開した後にデータの形を変えるのは緊張しましたが、最初の版で「形式のバージョン」と「移行処理を追加するだけの仕組み」を用意しておいたおかげで、落ち着いて進められました。

コメント