RORK LABEN
PLAY — Google Play の target API level 36 要件が昨日8月31日に発効しました。今日以降、新規アプリと既存アプリの更新は Android 16 対応が必須ですVISIBILITY — API 35 のままのアプリは掲載こそ続きますが、新しい Android 版のユーザーには表示されなくなります。エラーが出ないまま新規インストールだけが減る点に注意が要りますEXTENSION — 間に合わなかった場合は、Play Console から2026年11月1日までの延長申請が出せます。恒久対応の計画とセットで進めるのが実務的ですAPPLE — Apple 側は9月9日にイベント、iOS 27 の正式リリースは9月14日と報じられています。生成したアプリの iOS 27 実機確認はリリース週の前に済ませておきたいところですEXPO — Expo が expo-paste-input を公開しました(8月28日)。React Native の TextInput に画像・GIF・ステッカーの貼り付けを追加するネイティブモジュールですEAS — EAS Observe が8月20日に GA になりました。クラッシュや性能の観測を、ビルドや配信と同じ EAS 上で持てるようになっていますPLAY — Google Play の target API level 36 要件が昨日8月31日に発効しました。今日以降、新規アプリと既存アプリの更新は Android 16 対応が必須ですVISIBILITY — API 35 のままのアプリは掲載こそ続きますが、新しい Android 版のユーザーには表示されなくなります。エラーが出ないまま新規インストールだけが減る点に注意が要りますEXTENSION — 間に合わなかった場合は、Play Console から2026年11月1日までの延長申請が出せます。恒久対応の計画とセットで進めるのが実務的ですAPPLE — Apple 側は9月9日にイベント、iOS 27 の正式リリースは9月14日と報じられています。生成したアプリの iOS 27 実機確認はリリース週の前に済ませておきたいところですEXPO — Expo が expo-paste-input を公開しました(8月28日)。React Native の TextInput に画像・GIF・ステッカーの貼り付けを追加するネイティブモジュールですEAS — EAS Observe が8月20日に GA になりました。クラッシュや性能の観測を、ビルドや配信と同じ EAS 上で持てるようになっています
記事一覧/AIモデル
AIモデル/2026-04-17上級

プッシュ通知を『送るのをやめた』ら離脱率が下がった話 — Rork Max で作るAIチャーン予測通知システムの全設計

ユーザー行動データから離脱の予兆を数値化し、Claude APIで個別最適化した通知文を生成してOneSignalで配信する仕組みをRork Maxで実装します。Supabaseでのイベント収集とスコア計算、送りすぎない設計、効果測定までコード付きで解説します。

チャーン予測プッシュ通知19AI30Supabase33Claude API11OneSignal2Rork Max232ユーザーリテンション

個人開発でアプリをリリースして最初にやってしまいがちな間違いのひとつが、「毎日リマインド通知を送る」という施策です。

私が最初に作ったアプリでも同じことをしました。登録してくれたユーザー全員に、毎朝9時に「今日もアプリを使いましょう!」という通知を送り続けた結果、1週間後には通知の許可を取り消したユーザーが40%を超えていました。問題はそれだけでなく、通知許可を残しているユーザーの開封率も3%台まで落ちていて、通知がほぼ無意味になっていたんです。

転換点になったのは、プッシュ通知の設計を「全員に送る」から「離脱しそうな人だけに、その人に合ったメッセージを送る」に変えたときでした。同じ月のチャーン率(翌月も使い続けるかどうか)が、前月比で約28%改善しました。

そのシステムの全工程を、これから一緒に組み上げていきます。ユーザーの行動データからチャーン予兆スコアを計算し、Claude APIでそのユーザーに最適化した通知文を自動生成し、OneSignalで配信するまで、実際のコードを交えながら解説します。

なぜ「通知を減らすと」チャーン率が下がるのか

直感に反しますが、プッシュ通知の頻度と継続率は逆相関することがあります。

先ほどの40%という数字を、私は長いあいだ「文面が下手だったせいだ」と思っていました。言い回しを何度も書き直しました。数字は動きませんでした。

見方を変えたのは、通知のログとセッションログを同じ時間軸に並べたときです。問題は文面ではなく、届く瞬間が受け手の状況とまったく噛み合っていないことでした。

昨日もアプリを開いた人に「使い忘れていませんか?」と送る。この一通が「このアプリは私を見ていない」という判断を作ります。文面の善し悪しは、その判断のうしろに隠れてしまいます。

そして一度オフにされた通知許可は、こちらから戻す手段がありません。アプリ内でどれだけ丁寧にお願いしても、iOS の設定画面まで戻って再許可してくれる方はごく少数です。通知許可は、使い切ったら補充できない資源。そう考えたほうが実態に近いと感じています。

理想的なプッシュ通知の設計はこうなります。

  • アクティブユーザーには送らない(使ってくれているので不要)
  • 3日間使っていないユーザーにだけ送る(離脱予兆あり)
  • しかも、その人の使い方に合わせたメッセージを送る(「習慣トラッカーを3日ぶりに確認する絶好のタイミングかもしれません」)

この「誰に・何を送るか」の判断を自動化するのが、チャーン予測通知システムです。

チャーンスコアリングの設計 — 行動データで「離脱予兆」を数値化する

チャーン予測と聞くと機械学習モデルのトレーニングを想像するかもしれませんが、個人開発の規模ではルールベースのスコアリングで十分な精度が出ます。複雑なMLモデルよりもシンプルなルールの方が、デバッグしやすく運用コストも低いです。

私が実際に使っているスコアリングモデルは以下のロジックです。

// churn-score.ts — チャーンスコア計算ロジック
// スコアが高いほど「離脱リスクが高い」
 
interface UserActivity {
  userId: string;
  lastOpenAt: Date;       // 最後にアプリを開いた日時
  sessionCount7d: number; // 過去7日間のセッション数
  sessionCount30d: number; // 過去30日間のセッション数
  hasPremium: boolean;    // 有料会員かどうか
  notificationsSent: number; // 過去7日間に送った通知数(送りすぎ防止)
  notificationOpened: boolean; // 直近の通知を開いたか
}
 
function calculateChurnScore(user: UserActivity): number {
  let score = 0;
  const now = new Date();
  const daysSinceLastOpen = Math.floor(
    (now.getTime() - user.lastOpenAt.getTime()) / (1000 * 60 * 60 * 24)
  );
 
  // ① 最終接触からの経過日数(最も重要な指標)
  if (daysSinceLastOpen >= 7) score += 40;
  else if (daysSinceLastOpen >= 3) score += 20;
  else if (daysSinceLastOpen >= 1) score += 5;
  else return -1; // 今日使ったユーザーは通知不要、-1 で除外
 
  // ② 週次利用頻度(低いほどリスク高)
  if (user.sessionCount7d === 0) score += 30;
  else if (user.sessionCount7d <= 2) score += 15;
  else if (user.sessionCount7d <= 5) score += 5;
  else score -= 10; // ヘビーユーザーはリスク低
 
  // ③ 月次エンゲージメント推移
  const weeklyAvg = user.sessionCount30d / 4;
  if (user.sessionCount7d < weeklyAvg * 0.5) score += 15; // 先月比50%以下
  else if (user.sessionCount7d < weeklyAvg * 0.7) score += 8;
 
  // ④ 有料会員は離脱コストが高い(=通知効果も高い)
  if (user.hasPremium) score += 10;
 
  // ⑤ 通知の送りすぎ防止(スパム化の検出)
  if (user.notificationsSent >= 3 && !user.notificationOpened) {
    score -= 20; // 3回送って開かなかった → しばらく送らない
  }
 
  return Math.max(0, Math.min(100, score));
}
 
// 判定基準
// 60以上: 高リスク(通知必要)
// 30-59: 中リスク(様子見・1回通知)
// 0-29: 低リスク(通知不要)

このロジックのポイントは「今日使ったユーザーは即座に除外する(-1を返す)」設計です。アクティブユーザーへの不要な通知を完全に防げます。

Supabase でユーザー行動イベントを収集する

スコア計算に必要なデータを集めるため、Rork Maxが生成したアプリにイベントトラッキングを追加します。

Supabaseを選ぶ理由は、後述するEdge Functionsと同じ環境で動くため、スコア計算からデータ取得まで一気通貫で書けるからです。

まず、Supabase上にイベントテーブルを作成します。

-- Supabase SQL Editor で実行
create table user_events (
  id uuid default gen_random_uuid() primary key,
  user_id uuid references auth.users not null,
  event_name text not null,         -- 'app_open', 'feature_used', 'session_end' など
  created_at timestamptz default now(),
  properties jsonb                  -- 追加メタデータ(どの機能を使ったか等)
);
 
-- インデックスはuser_id + created_at の複合が必須(スコア計算でよく使う)
create index idx_user_events_user_created 
  on user_events(user_id, created_at desc);
 
-- RLS: 自分のイベントのみ書き込み可
alter table user_events enable row level security;
 
create policy "users can insert own events"
  on user_events for insert
  with check (auth.uid() = user_id);

次に、Rork Maxが生成したアプリコードにイベント送信を追加します。Rork Maxのプロンプトで「アプリが開かれるたびにSupabaseのuser_eventsテーブルにapp_openイベントを記録する」と指示すれば、対応するコードを生成してくれます。

生成されたコードに追加で手を加えるべき箇所を示します。

// App.tsx または _layout.tsx への追加
import { supabase } from '@/lib/supabase';
import { useEffect } from 'react';
import { AppState } from 'react-native';
 
export function useAppEventTracking() {
  useEffect(() => {
    // アプリがフォアグラウンドに来るたびに記録
    const subscription = AppState.addEventListener('change', async (state) => {
      if (state === 'active') {
        const { data: { user } } = await supabase.auth.getUser();
        if (!user) return;
 
        await supabase.from('user_events').insert({
          user_id: user.id,
          event_name: 'app_open',
          properties: { timestamp: new Date().toISOString() }
        });
      }
    });
 
    return () => subscription.remove();
  }, []);
}

重要なのは「エラーが発生してもアプリの動作を止めない」設計です。イベント送信の失敗でアプリがクラッシュするのは最悪です。Supabaseのinsertはエラーをthrowせず、結果オブジェクトで返すので、awaitするだけで問題ありません。

Supabase Edge Function でチャーンスコアを計算する

チャーンスコアの計算はサーバー側で実行します。クライアント側でやると、ユーザーが意図的にスコアを操作できてしまうからです。

Supabase Edge Functionsを使えば、Supabaseのデータに直接アクセスしながらDenoのTypeScript環境でロジックを書けます。

// supabase/functions/calculate-churn-scores/index.ts
// このFunctionはcronで毎日1回呼び出す
 
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import { Anthropic } from 'https://esm.sh/@anthropic-ai/sdk@0.20.0';
 
const supabase = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! // service_roleキーはサーバー側のみ
);
 
const anthropic = new Anthropic({
  apiKey: Deno.env.get('ANTHROPIC_API_KEY')!
});
 
Deno.serve(async (req) => {
  // cronからのリクエストのみ許可
  const authHeader = req.headers.get('Authorization');
  if (authHeader !== `Bearer ${Deno.env.get('CRON_SECRET')}`) {
    return new Response('Unauthorized', { status: 401 });
  }
 
  try {
    // 過去30日間にサインアップしたアクティブユーザーを取得
    const { data: users, error } = await supabase
      .from('profiles')
      .select('id, onesignal_player_id, app_usage_summary')
      .not('onesignal_player_id', 'is', null) // 通知を許可しているユーザーのみ
      .gte('created_at', new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString());
 
    if (error) throw error;
 
    const notifications = [];
 
    for (const user of users ?? []) {
      // 過去7日間のセッション数を集計
      const { count: sessions7d } = await supabase
        .from('user_events')
        .select('*', { count: 'exact', head: true })
        .eq('user_id', user.id)
        .eq('event_name', 'app_open')
        .gte('created_at', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString());
 
      // 最終アクセス時刻を取得
      const { data: lastEvent } = await supabase
        .from('user_events')
        .select('created_at')
        .eq('user_id', user.id)
        .eq('event_name', 'app_open')
        .order('created_at', { ascending: false })
        .limit(1)
        .single();
 
      if (!lastEvent) continue;
 
      const activity = {
        userId: user.id,
        lastOpenAt: new Date(lastEvent.created_at),
        sessionCount7d: sessions7d ?? 0,
        sessionCount30d: user.app_usage_summary?.sessions30d ?? 0,
        hasPremium: user.app_usage_summary?.has_premium ?? false,
        notificationsSent: user.app_usage_summary?.notifications_sent_7d ?? 0,
        notificationOpened: user.app_usage_summary?.last_notification_opened ?? false,
      };
 
      const score = calculateChurnScore(activity);
      
      // スコア30以上のユーザーのみ通知対象
      if (score >= 30) {
        notifications.push({
          userId: user.id,
          playerId: user.onesignal_player_id,
          score,
          activity,
        });
      }
    }
 
    // スコアが高い順に並べ替えて通知を生成・送信
    notifications.sort((a, b) => b.score - a.score);
    
    let sentCount = 0;
    for (const target of notifications) {
      const message = await generatePersonalizedMessage(target.activity, anthropic);
      await sendNotification(target.playerId, message);
      sentCount++;
      
      // レート制限対策(1日300件まで)
      if (sentCount >= 300) break;
      await new Promise(resolve => setTimeout(resolve, 100)); // 100msスリープ
    }
 
    return new Response(
      JSON.stringify({ processed: notifications.length, sent: sentCount }),
      { headers: { 'Content-Type': 'application/json' } }
    );
  } catch (error) {
    console.error('Churn notification error:', error);
    return new Response(
      JSON.stringify({ error: error.message }),
      { status: 500, headers: { 'Content-Type': 'application/json' } }
    );
  }
});

Claude API で「その人だけへの」通知文を自動生成する

ここが、このシステムの核心です。全員に同じ通知文を送るのではなく、そのユーザーの利用パターンから最適なメッセージをClaudeに生成させます。

// 通知文を生成する関数
async function generatePersonalizedMessage(
  activity: UserActivity,
  anthropic: Anthropic
): Promise<{ title: string; body: string }> {
  const daysSinceLastOpen = Math.floor(
    (new Date().getTime() - activity.lastOpenAt.getTime()) / (1000 * 60 * 60 * 24)
  );
 
  // ユーザーのコンテキストをClaudeに伝える
  const prompt = `
あなたはフィットネストラッキングアプリのプッシュ通知を書くAIです。
以下のユーザー情報をもとに、自然で温かみのある通知文を作成してください。
 
ユーザー情報:
- 最後にアプリを開いてから: ${daysSinceLastOpen}日
- 先週のセッション数: ${activity.sessionCount7d}回
- 先月の週平均セッション: ${(activity.sessionCount30d / 4).toFixed(1)}回
- プレミアム会員: ${activity.hasPremium ? 'はい' : 'いいえ'}
 
要件:
- タイトルは15文字以内
- 本文は40文字以内
- 絵文字を1〜2個使う
- 「機能を使ってください」のような命令形は避ける
- 継続を責めるのではなく、戻ってきやすい雰囲気を作る
 
JSON形式で出力:
{"title": "...", "body": "..."}
`;
 
  const response = await anthropic.messages.create({
    model: 'claude-haiku-4-5-20251001', // 大量生成には Haiku が最適
    max_tokens: 200,
    messages: [{ role: 'user', content: prompt }]
  });
 
  const content = response.content[0];
  if (content.type !== 'text') {
    // フォールバックメッセージ
    return { title: '久しぶりです 👋', body: 'またいつでも戻ってきてください' };
  }
 
  try {
    return JSON.parse(content.text);
  } catch {
    // JSONパース失敗時のフォールバック
    return { title: '久しぶりです 👋', body: 'またいつでも戻ってきてください' };
  }
}

この設計で重要なのは「フォールバックを必ず用意する」点です。Claude APIが何らかの理由で期待通りのJSONを返さないケースは稀ではありません。本番環境では、デフォルトメッセージへの切り替えが安全に行われるよう設計する必要があります。

また、大量のユーザーへの通知生成コストを抑えるため、高価なOpusやSonnetではなくHaikuを使っています。通知文の生成は創造的な文章ではなく定型パターンに近いので、Haikuで十分な品質が出ます。

OneSignal × Rork Max でターゲット通知を送る

生成したメッセージをOneSignal経由で配信します。OneSignalはRork Maxとの統合が比較的シンプルで、OneSignalのRork向けドキュメントに従って設定できます。

詳細なOneSignalの導入手順については、Rork Labの OneSignal完全ガイドを参照してください。ここでは通知送信のAPIコール部分のみ紹介します。

// OneSignal REST API で通知を送信
async function sendNotification(
  playerId: string,
  message: { title: string; body: string }
): Promise<boolean> {
  const ONESIGNAL_APP_ID = Deno.env.get('ONESIGNAL_APP_ID')!;
  const ONESIGNAL_REST_API_KEY = Deno.env.get('ONESIGNAL_REST_API_KEY')!;
 
  const response = await fetch('https://onesignal.com/api/v1/notifications', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Basic ${ONESIGNAL_REST_API_KEY}`,
    },
    body: JSON.stringify({
      app_id: ONESIGNAL_APP_ID,
      include_player_ids: [playerId],
      headings: { en: message.title, ja: message.title },
      contents: { en: message.body, ja: message.body },
      // チャーン通知専用のデータタグを付ける(効果測定に使う)
      data: { notification_type: 'churn_prevention', sent_at: new Date().toISOString() },
    }),
  });
 
  if (!response.ok) {
    const errorData = await response.json();
    console.error('OneSignal送信エラー:', errorData);
    return false;
  }
 
  return true;
}

dataフィールドにnotification_type: 'churn_prevention'を含めることで、通知経由でアプリを開いたユーザーを識別できます。これが後述するA/Bテストや効果測定の基礎データになります。

日次実行をどこに置くか — cron を「1日1回」に固定する

ここまでの Edge Function は、誰かが呼ばないと一生動きません。最初は Cloudflare Workers の cron トリガーから叩いていましたが、いまは Supabase の pg_cron に寄せています。スコアを読むテーブルとスケジュールが同じ場所にある分、片方だけ移設して壊す事故が起きにくいからです。

create extension if not exists pg_cron;
create extension if not exists pg_net;
 
-- 21:00 UTC = 翌 06:00 JST に1回だけ実行する
select cron.schedule(
  'daily-churn-scoring',
  '0 21 * * *',
  $$
  select net.http_post(
    url := 'https://YOUR_PROJECT_REF.supabase.co/functions/v1/calculate-churn-scores',
    headers := '{"Content-Type":"application/json","Authorization":"Bearer YOUR_CRON_SECRET"}'::jsonb,
    body := '{}'::jsonb
  );
  $$
);

YOUR_CRON_SECRET は Edge Function 側の環境変数と一致させ、関数の冒頭で照合してください。エンドポイントは公開URLなので、これが無いと誰でも通知バッチを起動できてしまいます。

ここで踏みやすい穴が二つあります。

① cron 式は UTC で書かれている

pg_cron の時刻は UTC 基準です。日本時間の朝6時に届けたければ 0 21 * * * と書くことになります。手元の感覚のまま 0 6 * * * と書くと、通知は深夜3時に飛びます。JST は夏時間が無い分、UTC からの差が年間を通して9時間で固定なので、この一点だけ覚えておけば十分です。

② 同じ日に二度走る

デプロイ直後の再実行や、タイムアウトしたリクエストのリトライで、バッチが二重に起動することがあります。last_churn_notification_at を見るだけでは、同一トランザクション内で並走した二本を止められません。その日の「実行枠」をテーブルで一つだけ確保する形にすると、確実に冪等になります。

create table if not exists churn_batch_runs (
  run_date      date primary key,
  started_at    timestamptz default now(),
  notified_count integer default 0
);
// Edge Function の冒頭。今日の枠を取れたときだけ先へ進む
const { data: slot } = await supabase
  .from('churn_batch_runs')
  .upsert(
    { run_date: new Date().toISOString().slice(0, 10) },
    { onConflict: 'run_date', ignoreDuplicates: true }
  )
  .select('run_date');
 
if (!slot || slot.length === 0) {
  // 今日の分はすでに別の起動が処理している
  return new Response(JSON.stringify({ skipped: 'already_ran_today' }), { status: 200 });
}

ignoreDuplicates: true を付けた upsert は、既に行があると空配列を返します。その戻り値をそのまま「今日はもう走った」の判定に使えるので、別途ロックを持つ必要がありません。

私はこの仕組みを入れる前に、リトライで同じユーザーに2通続けて送ってしまったことがあります。ユーザーから見れば、離脱防止どころか単なる連投です。通知を送るバッチは、送らない判断と同じくらい「二度送らない」ことに気を配る価値があります。

よくある落とし穴 — 「スコアが高いユーザーに全員送る」は間違い

実装してみて最初につまずきやすいポイントをまとめます。

① スコア60以上を全員対象にすると通知疲れが起きる

高リスクユーザーが500人いたとして、500人全員に送るのは誤りです。一度に大量に送ると、開封率が低い通知が増えてApple/Googleのスパム判定ロジックに影響します。私は1日の最大送信数を「スコア上位30%のユーザー」または「300件」のいずれか少ない方に制限しています。

② 同じユーザーに連日送り続ける

チャーン予測スコアが高いユーザーに毎日通知を送ると、むしろアンインストールを誘発します。同一ユーザーへの通知は7日間で1回を上限にすべきです。Supabaseのprofilesテーブルにlast_churn_notification_atカラムを追加して管理します。

-- 通知済みのユーザーを追跡するカラム
alter table profiles 
  add column last_churn_notification_at timestamptz,
  add column notifications_sent_7d integer default 0;
 
-- Edge Functionで送信後に更新
update profiles 
set last_churn_notification_at = now(),
    notifications_sent_7d = notifications_sent_7d + 1
where id = '...';

③ Claudeが生成した通知文をサニタイズしない

Claude APIのレスポンスをそのままユーザーに送るのは危険です。特にJSONのパースが必要な場合、try/catchでのエラー処理と、フォールバックメッセージへの切り替えは必須です。また、出力文字数が設定した制限を超えていないか確認するバリデーションも加えてください。

function validateNotificationMessage(msg: { title: string; body: string }): boolean {
  if (typeof msg.title !== 'string' || msg.title.length > 50) return false;
  if (typeof msg.body !== 'string' || msg.body.length > 100) return false;
  // 不適切なコンテンツの簡易チェック(必要に応じて強化)
  const forbidden = ['エラー', 'undefined', 'null', '{', '}'];
  if (forbidden.some(word => msg.title.includes(word) || msg.body.includes(word))) return false;
  return true;
}

④ コスト計算を事前にしていない

ここは私自身が見積もりを間違えていた箇所なので、実際のトークン数から計算し直しておきます。

上のプロンプトは日本語で約450トークン、返ってくるJSONは約80トークンです。Claude Haiku 4.5 の料金は入力 $1 / 100万トークン、出力 $5 / 100万トークンなので、1通あたりのコストはこうなります。

内訳1通あたり1日300通30日
入力 約450トークン$0.00045$0.135$4.05
出力 約80トークン$0.00040$0.120$3.60
合計$0.00085$0.26$7.65

1日300通の上限を守っている限り、月に10ドルも届きません。生成コストは、このシステムを止める理由にはならない水準です。

危ないのは単価ではなく、送信対象の絞り込みが外れたときです。上限を外して1万人全員に毎日生成すれば、同じ単価でも1日$8.5、月に$255になります。コストが跳ねる原因はほぼ常に「誰に送るか」のバグであって、モデルの選択ではありません。

さらに削るなら、通知生成はリアルタイム性が要らない処理なので Batch API に載せられます。半額になるので、上の$7.65は$3.83まで下がります。プロンプトの固定部分に prompt caching をかければ入力側もさらに落ちます。

そのうえで、スコア50以上にだけClaudeを使い、30〜49には手書きテンプレートから選ぶ二段構えにしておくと、生成の失敗が波及する範囲も同時に狭まります。私はコスト目的というより、こちらの理由で二段構えにしています。

効果測定と継続改善のフレームワーク

このシステムを入れただけで満足せず、継続的に改善するための測定設計も大切です。

チャーン防止通知の効果を測る指標は、次の3つが基本です。

  • 通知開封率(目標: 15%以上): 全員送信の3%と比べて大幅に高いはずです
  • 30日後継続率(通知した群 vs 送らなかった群): A/Bテストで比較する
  • チャーンスコアの変化: 通知後にスコアが下がったか(=アプリを再び使ったか)

A/Bテストの設計はシンプルにします。チャーンスコア30以上のユーザーを半分に分け、通知を送るグループと送らないグループを作ります。30日後に両グループの継続率を比較します。

// A/Bグループ割り当て(user_idのハッシュで安定して振り分ける)
function getNotificationGroup(userId: string): 'treatment' | 'control' {
  const hash = userId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
  return hash % 2 === 0 ? 'treatment' : 'control';
}

この測定をきちんとやることで、「通知を送ることの実際の効果」が数値で見えるようになります。効果がなければ送るのをやめるという判断も、データに基づいてできます。

ユーザーリテンションの全体設計についてはさらに詳しい解説をRork Labのリテンション最大化ガイドにまとめています。また、PostHogを使ったユーザー行動分析の基本はPostHog導入ガイドが参考になります。

このシステムを動かし続けるために

最後に、このシステムを本番で運用するうえで必要なインフラの整理です。

Supabaseの無料プランでは、Edge Functionsの実行回数に上限があります。毎日1回のcron実行なら月30回なので無料枠で収まりますが、ユーザーが増えてきたらProプランへの移行を検討してください。

Claude APIのコスト管理については、Anthropic Consoleで使用量アラートを設定しておくことをおすすめします。ループのバグで意図せず大量リクエストが発生した場合の被害を最小化できます。

まずは「3日間使っていないユーザーに1通だけ送る」という最もシンプルな形から始めてみてください。スコアリングロジックもAI生成も後から足せます。大切なのは、全員への一斉送信をやめることです。それだけで、多くの場合は通知の効果が改善します。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥2,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

AI モデル2026-04-10
Rork × AI パーソナライゼーションエンジン実装ガイド — ユーザー行動を学習してUI・コンテンツ・通知を自動最適化するインテリジェントアプリの設計
Rork MaxでAI駆動のパーソナライゼーションエンジンを構築する上級ガイド。ユーザー行動のリアルタイム学習、UIの動的最適化、コンテンツの個別配信、通知タイミングの自動調整まで、壁紙系・癒し系のアプリを個人で運用してきた経験から、本番で効いた実装パターンと、ドキュメントに載らない運用上の判断を具体的に共有します。
AI モデル2026-03-30
Rork × AI Function Calling で構築するインテリジェントアシスタントアプリ — コンテキスト管理・ツール統合・会話メモリの実装パターン
AI Function Callingを軸に、外部API連携・会話メモリ・動的ツール選択を備えたアシスタントアプリをRorkで構築する上級ガイドです。ツール定義からレスポンス処理、長期記憶の設計、セキュリティとレートリミットまで、運用で気づいた点も交えて解説します。
AI モデル2026-06-24
Rork Max の AI 機能を本気で使い倒す — Vibe Coding を超える実装力の引き出し方
Rork Max を実装パートナーとして使い倒すための深掘りガイドです。プロンプト設計とコンテキスト管理に加え、無印 Rork と Rork Max の選び分け、クレジットを無駄にしない作り方、App Store 提出前の検証までを、個人開発の実体験からまとめました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →