0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

〖個人開発PWA〗CoupleOpsのWeek2:バリデーション更新に追いつくデータ移行と、感情にあわせた広告制御を入れた話

Posted at

はじめに

一緒に暮らし始めたカップル向け「毎日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 を用意して、「最悪リセットすれば復帰できる」出口も作った
  • Daily Reset(負荷レベル & 感謝)から ローカル専用の「Mood」状態 を算出して、

    • 負荷が高いときはバナーだけ
    • 負荷が軽くて感謝を書けた日は、インタースティシャルや In-feed も解禁
      という「感情にあわせた広告モード」を導入
  • ユーザーの Mood 情報はあくまで localStorage にだけ保存し、広告ネットワーク側には送らない構造にして、プライバシーと体験の両方をそれなりに両立させた


1. 既存データと新バリデーションがケンカした話

背景:必須項目を増やしたら、過去データが開けなくなった

CoupleOps には CBT や感謝介入などの「心理ワーク」がいくつかあり、それぞれに JSON スキーマ的なバリデーションがあります。

  • 開発を進めるなかで、「やっぱりこのフィールドは必須にしたい」「繰り返し項目を増やしたい」という変更が入る

  • ところが、既に保存されている古いレコードには、そのフィールドが存在しない

  • その状態でフォームを開くと、

    • UI 側は「必須項目がない」と怒る
    • 保存ボタンも押せない
    • Reset もできない
      → ユーザー視点だと「なぜかそのワークだけ二度と開けない箱」になってしまう

という危ない状態になりかけました。

方針

やりたいことはシンプルで:

  1. 既存の値は絶対に上書きしない

  2. 新しく追加された必須フィールドだけ、最低限フォームが開けるプレースホルダーを入れる

  3. それでもうまくいかない時のために、

    • 設定画面からワークデータをリセットできる
    • 「なぜ保存できない時があるのか?」を 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 にも「この操作は元に戻せません」と明示

しておきました。

バックアップ機能と組み合わせると、

  1. まず JSON でフルバックアップを取る
  2. どうしてもおかしい時は Settings から対象ワークをリセット
  3. それでもダメならバックアップから戻す

という避難ルートが持てるので、だいぶ気持ち的にも楽になりました。


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 コーチ」としての部分に手を入れていく予定です。

ここまで読んでいただきありがとうございました。
「こういうワークがあったら使ってみたい」「このあたりの設計どうしてる?」などあれば、コメントで教えてもらえると嬉しいです。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?