はじめに
一緒に暮らし始めたカップル向け「毎日2分アプリ」こと CoupleOps を個人開発しています。
前回の記事では、
- PWA の箱づくりと 30秒ループ(Daily Reset)
- テーマ切替・同意ゲート・下部固定 AdSense バナー
- フルバックアップ(Dexie + localStorage)と週次ふりかえり
あたりを書きました。
その後も少しずつ改善を入れていく中で、
- ワークのバリデーション(必須項目)を増やしたら、既存データが壊れかけた
- Daily Reset の「今日の負荷」や「感謝」をうまく使って、広告が“空気を読む”ようにしたい
という 2 つの課題が出てきたので、この記事ではそのあたりの実装メモを残しておきます。
アプリのβ版はこちら(内容は今後も変わる予定です)
https://coupleops-app.pages.dev
TL;DR
-
ワークのスキーマに必須項目を追加しても既存データが壊れないよう、
- Dexie の
version(3).upgrade()で 「足りない必須フィールドだけ」埋めるマイグレーション を実装 - Settings に「ワークデータのリセット」UI、Guide に Q&A を用意して、「最悪リセットすれば復帰できる」出口も作った
- Dexie の
-
Daily Reset(負荷レベル & 感謝)から ローカル専用の「Mood」状態 を算出して、
- 負荷が高いときはバナーだけ
- 負荷が軽くて感謝を書けた日は、インタースティシャルや In-feed も解禁
という「感情にあわせた広告モード」を導入
-
ユーザーの Mood 情報はあくまで localStorage にだけ保存し、広告ネットワーク側には送らない構造にして、プライバシーと体験の両方をそれなりに両立させた
1. 既存データと新バリデーションがケンカした話
背景:必須項目を増やしたら、過去データが開けなくなった
CoupleOps には CBT や感謝介入などの「心理ワーク」がいくつかあり、それぞれに JSON スキーマ的なバリデーションがあります。
-
開発を進めるなかで、「やっぱりこのフィールドは必須にしたい」「繰り返し項目を増やしたい」という変更が入る
-
ところが、既に保存されている古いレコードには、そのフィールドが存在しない
-
その状態でフォームを開くと、
- UI 側は「必須項目がない」と怒る
- 保存ボタンも押せない
- Reset もできない
→ ユーザー視点だと「なぜかそのワークだけ二度と開けない箱」になってしまう
という危ない状態になりかけました。
方針
やりたいことはシンプルで:
-
既存の値は絶対に上書きしない
-
新しく追加された必須フィールドだけ、最低限フォームが開けるプレースホルダーを入れる
-
それでもうまくいかない時のために、
- 設定画面からワークデータをリセットできる
- 「なぜ保存できない時があるのか?」を Guide/Q&A に書いておく
という 3 つの保険をかけることにしました。
2. Dexie v3 マイグレーションで「足りない必須フィールドだけ」埋める
required フィールドのマップを作る
src/workflows/engine/db.ts 側で、ワークごとのメタ情報(validation)から「必須フィールド一覧」を引けるようにしました。
(ここは実コードをそのまま貼るより、ざっくりイメージだけ)
// workId → WorkEntry メタ(必須フィールドなど)
type RequiredFieldInfo = {
simple: { id: string; type: 'text' | 'date' }[];
repeaters: {
id: string;
itemRequired: string[];
}[];
};
function buildRequiredFieldMap(): Map<string, RequiredFieldInfo> {
const map = new Map<string, RequiredFieldInfo>();
for (const bundle of allWorkBundles) {
const simple: RequiredFieldInfo['simple'] = [];
const repeaters: RequiredFieldInfo['repeaters'] = [];
// バリデーション定義から required な field を抽出する処理…
map.set(bundle.work.id, { simple, repeaters });
}
return map;
}
migratePayload: 既存値は触らず、空のところだけ埋める
const MIGRATION_PLACEHOLDER = '(この項目はアップデートで追加されました)';
function isEmpty(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
return false;
}
function formatDateForInput(ts: number): string {
const d = new Date(ts);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function migratePayload(
workId: string,
createdAt: number,
payload: WorkEntryPayload | undefined,
requiredMap: Map<string, RequiredFieldInfo>,
): WorkEntryPayload {
const info = requiredMap.get(workId);
if (!info) return payload ?? {};
const next: WorkEntryPayload = { ...(payload ?? {}) };
// 1) 単一フィールド
for (const field of info.simple) {
const current = (next as any)[field.id];
if (!isEmpty(current)) continue; // ★ 既存値は絶対触らない
if (field.type === 'date') {
(next as any)[field.id] = formatDateForInput(createdAt);
} else {
(next as any)[field.id] = MIGRATION_PLACEHOLDER;
}
}
// 2) 繰り返しフィールド
for (const { id, itemRequired } of info.repeaters) {
const raw = (next as any)[id];
const rows: any[] = Array.isArray(raw) ? [...raw] : [];
if (rows.length === 0) {
const row: any = {};
for (const subId of itemRequired) {
row[subId] = MIGRATION_PLACEHOLDER;
}
rows.push(row);
} else {
for (const row of rows) {
for (const subId of itemRequired) {
if (!isEmpty(row[subId])) continue;
row[subId] = MIGRATION_PLACEHOLDER;
}
}
}
(next as any)[id] = rows;
}
return next;
}
- 重要なのはコメントにも書いている通り 「既存値は絶対上書きしない」 こと。
- そのうえで、フォームが壊れない最低限のプレースホルダーを入れる、という考え方です。
Dexie v3 の upgrade ハンドラ
this.version(3)
.stores({
entries_v2: 'id, workId, createdAt, updatedAt',
})
.upgrade(async (tx) => {
const table = tx.table<WorkEntryRecord>('entries_v2');
const all = await table.toArray();
const requiredMap = buildRequiredFieldMap();
for (const entry of all) {
try {
const payload = migratePayload(
entry.workId,
entry.createdAt,
entry.payload,
requiredMap,
);
await table.put({ ...entry, payload });
} catch (e) {
console.warn('Failed to migrate entry', entry.id, e);
}
}
});
3. 設定画面に「ワークデータのリセット」を追加
マイグレーションでだいぶ安全にはなったものの、
- β版の間はバリデーションの試行錯誤も続く
- ユーザー自身が「このワーク、一回まっさらからやり直したい」と思うこともありそう
だったので、Settings に 「ワークデータのリセット」セクション を追加しました。
-
プルダウンで対象ワーク(例:Daily Reset / 自動思考トラッカー など)を選択
-
「選んだワークの記録を消す」ボタン
-
deleteEntriesByWorkId(workId)を呼び出し、Dexie の該当レコードのみ削除
-
-
「心理ワークの記録をすべて消す」ボタン
-
clearAllEntries()を呼び出し、Dexie の entries テーブルを全削除
-
どちらも 取り消し不可 なので、
- ボタンのラベルと説明文をかなり強めに
- Guide ページの Q&A にも「この操作は元に戻せません」と明示
しておきました。
バックアップ機能と組み合わせると、
- まず JSON でフルバックアップを取る
- どうしてもおかしい時は Settings から対象ワークをリセット
- それでもダメならバックアップから戻す
という避難ルートが持てるので、だいぶ気持ち的にも楽になりました。
4. Daily Reset から「広告モード」を決める adContext
次のテーマは「広告が空気を読む」ことです。
アプリとしては AdSense を入れているものの、
- パートナーとケンカしてぐったりしているタイミングで
いきなり全画面広告が出るのはつらい - 一方で、気持ちが軽くなって「今日もやるか」と思えているときは
多少の広告は受け入れられそう
という感覚がありました。
そこで、Daily Reset の入力から ローカル専用の Mood 情報 を作り、
広告モードを 3 段階に分けました。
-
block: すべての広告を出さない(今はほぼ使っていない) -
bannerOnly: 下部バナーや控えめな In-feed だけ -
full: インタースティシャルを含めたフルセット
MoodState → AdMode の変換ロジックは、本メモの「5-2. MoodState と AdMode」を参照してください。
5. やってみての学び
-
バリデーションを変えるときは「既存データに何が起きるか?」を最初に想像するべし
- マイグレーションで「足りないところだけ埋める」
- それでも困ったときのために、ユーザー向けの「リセット」と Q&A を用意しておくと安心
-
広告は「出す/出さない」だけでなく、「いつなら出してもよさそうか」を考えると UX がだいぶ変わる
- Daily Reset の負荷や感謝は、Mood 推定のちょうどいい材料になった
- ただし Mood 情報はあくまで端末内で完結させることで、プライバシーとの折り合いをつけた
-
固定フッター + 広告 + モバイルブラウザのツールバーの組み合わせは、想像以上に縦方向のスペースを食う
-
min-height: 100vhと下側のpadding-bottomは早めに入れておいたほうがいい
-
おわりに
今回は、
- 既存データを壊さない Dexie マイグレーション
- Settings の「ワークデータのリセット」と Guide の Q&A
- Daily Reset の Mood から広告モードを切り替える処理
- インタースティシャル / In-feed / 下部バナーの連携
あたりを中心に、AI を使わない地味だけど大事な改善 をまとめました。
次はようやく、
- If–Then プランの提案
- 感謝文の言い換え
- 週次レビュー後の「来週のテーマ」生成
など、「AI コーチ」としての部分に手を入れていく予定です。
ここまで読んでいただきありがとうございました。
「こういうワークがあったら使ってみたい」「このあたりの設計どうしてる?」などあれば、コメントで教えてもらえると嬉しいです。