栽培管理アプリを作ってます⑬:作業マスタを作ったら一括操作が遅すぎた話

目次

はじめに

前回、複数人で使えるアプリにするための認証・権限管理を実装しました。

今回は、作業マスタ機能を追加したところ、一括操作の実装で躓いた話です。

1. 作業登録の課題:毎回ポチポチするのが面倒

栽培管理アプリでは、日々の作業を記録する「作業登録」機能があります。現状では以下の項目が選択できます:

  • 防除
  • 灌水
  • 施肥
  • その他(テキスト入力)

問題点

実際に使ってみると、なんかしっくりこない:

  • 「耕起」「定植」「収穫」など、防除・灌水・施肥以外の作業はすべて「その他」で毎回テキスト入力が必要
  • 同じ作業を何度も入力するのが面倒
  • 入力のたびに「前回なんて入力したっけ?」と迷う
  • 表記ゆれ(「耕起」「耕うん」「耕運」など)が発生する

よく考えれば、農作業の種類は農園ごとにある程度決まっています。それなのに毎回手入力するのは非効率でした。

肥料、防除などはマスタを作ってそこから選択
それ以外の作業はすべて「その他」から最初は入力しようとしていたが、なんか効率悪い。

2. 結局、作業項目も別で設けることに

作業マスタを作る

作業マスタとは、よく使う作業をあらかじめ登録しておき、作業記録時に選択できるようにする機能です。

  • 「耕起」「定植」「収穫」などを事前登録
  • 作業登録時はプルダウンから選択するだけ
  • 表記ゆれが防止できる
  • 入力が圧倒的に楽になる

実装方針

作業マスタは以下の要件で実装することにしました:

  • カテゴリ分け(圃場準備、定植・更新、栽培管理など)
  • お気に入り機能(よく使う作業だけを表示)
  • テンプレートから一括登録できるようにする

3. 初期データの一括登録機能を実装

workTypeTemplates.ts の作成

新規で農園を作る際に、ある程度の作業項目を一括登録できるようにしました。
色々検討しましたが、下記のような感じで77項目の作業内容に。

// workTypeTemplates.ts
export const workTypeTemplates = [
  { name: '耕起', category: 'field_prep' },
  { name: '畝立て', category: 'field_prep' },
  { name: '土壌改良', category: 'field_prep' },
  { name: '定植', category: 'planting' },
  { name: '播種', category: 'planting' },
  { name: '草刈り', category: 'management' },
  // ... 全77件
];

これで新規農園でも、最初から77件の作業項目が使える状態になりました。

カテゴリ分けの実装

作業をカテゴリごとに整理することで、目的の作業を探しやすくしました:

  • 圃場準備(11件):耕起、畝立て、マルチ敷設など
  • 定植・更新(11件):定植、播種、株更新など
  • 栽培管理(20件):灌水、施肥、摘芯など
  • 施設管理(11件):ハウス開閉、換気扇操作など
  • 保全作業(8件):点検・調整など
  • 事務作業(5件):出荷準備、書類作成など
  • その他(11件)

4. お気に入り機能で使いやすくする

77件もあると選ぶのが大変なので、お気に入り機能を追加しました。

実装内容

  • よく使う作業だけを「お気に入り」に登録
  • 作業登録画面では、お気に入りに登録した作業だけが表示される
  • 作業マスタ画面で、いつでもお気に入りの追加・削除ができる

5. 問題発生:お気に入り一括登録が遅すぎる

発生した問題

初期設定で「よく使う作業をまとめてお気に入り登録したい」というケースがあります。

例えば、77件全てをチェックして一括お気に入り登録を実行したところ:

  • 処理に30秒以上かかる
  • 削除も同様に30秒以上
  • 処理中は画面が固まったように見える
  • 「本当に登録されているのか?」と不安になる

実装していたコード(問題のあるコード)

// 一括お気に入り登録(遅い実装)
const handleBulkAddFavorite = async () => {
  let successCount = 0;
  for (const id of selectedIds) {
    try {
      await toggleFavoriteWorkTypeAction(id, true); // 1件ずつ処理
      successCount++;
    } catch {
      // エラーは無視
    }
  }

  setMessage({ 
    type: "success", 
    text: `${successCount}件をお気に入りに追加しました` 
  });
};

このコードは一見問題なさそうに見えますが、1件ずつ順番に処理しているのが問題でした。

6. 原因の特定:Chrome DevToolsで確認

Chrome DevToolsのNetworkタブで処理を確認してみました。

結果、以下のことが分かりました:

  • リクエストが77回、順番に実行されている
  • 各リクエストが前のリクエストの完了を待ってから開始(直列処理)
  • 1件あたり約400ms かかるため、77件 × 400ms = 約30秒

つまり、for...awaitループで1件ずつ順番に処理していたのが原因でした。

7. 最初の改善案:Promise.allで並列化

「並列処理にすれば速くなるはず!」と思い、まずはPromise.allを使って並列化してみました。

修正したコード

// Promise.allで並列化(でも速くならなかった...)
const handleBulkAddFavorite = async () => {
  const results = await Promise.allSettled(
    Array.from(selectedIds).map((id) => 
      toggleFavoriteWorkTypeAction(id, true)
    )
  );
  const successCount = results.filter(
    (r) => r.status === "fulfilled"
  ).length;

  setMessage({ 
    type: "success", 
    text: `${successCount}件をお気に入りに追加しました` 
  });
};

結果:体感は変わらず…

コードを修正して再度実行してみましたが、体感の速度はほとんど変わりませんでした

Networkタブでもリクエストが順番に実行されていました。

原因:Next.jsのServer Actionsは並列実行されない

調べてみると、Next.jsのServer Actionsには以下の制約がありました:

  • Supabaseの認証(Row Level Security)が絡むと、セッション管理の都合で順次実行になる
  • クライアント側でPromise.allを使っても、サーバー側で並列実行されるとは限らない

つまり、1件ずつServer Actionを呼び出す方式では、並列化できないということでした。

8. 本当の解決策:バックエンドで一括処理APIを作成

問題の本質は「77回のServer Action呼び出し」にあります。

解決策は、バックエンドに一括処理用のServer Actionを作り、1回の呼び出しで全件処理することです。

一括お気に入り登録APIの実装

actions.tsに以下の関数を追加しました:

// actions.ts
export async function bulkToggleFavoriteWorkTypesAction(
  ids: number[], 
  isFavorite: boolean
) {
  const supabase = await createClient();
  const { error } = await supabase
    .from('work_types')
    .update({ is_favorite: isFavorite })
    .in('id', ids); // SupabaseのIN句で一括更新

  if (error) throw error;
}

Supabaseの.in('id', ids)を使うことで、1回のクエリで複数件を更新できます。

一括削除APIの実装

同様に、一括削除用の関数も追加しました:

// actions.ts
export async function bulkDeleteWorkTypesAction(ids: number[]) {
  const supabase = await createClient();
  const { error } = await supabase
    .from('work_types')
    .delete()
    .in('id', ids); // SupabaseのIN句で一括削除

  if (error) throw error;
}

クライアント側の修正

// Client.tsx
const handleBulkAddFavorite = async () => {
  if (selectedIds.size === 0) return;

  setMessage({ type: "success", text: "処理中..." });

  try {
    // 一括処理APIを1回だけ呼び出す
    await bulkToggleFavoriteWorkTypesAction(
      Array.from(selectedIds), 
      true
    );

    setMessage({ 
      type: "success", 
      text: `${selectedIds.size}件をお気に入りに追加しました` 
    });
    setSelectedIds(new Set());
    router.refresh();
  } catch {
    setMessage({ type: "error", text: "処理に失敗しました" });
  }
};

結果:77件が一瞬で処理完了!

修正後、77件の処理が一瞬(約1秒)で完了するようになりました。

Networkタブでもリクエストが1回だけ実行されていることが確認できます。

9. ついでにUX改善も実装

速度改善のついでに、ユーザー体験も向上させました。

9-1. 処理中のメッセージ表示

処理開始時に「処理中…」、完了時に「○件を処理しました」と表示するようにしました。

これでユーザーが「ちゃんと動いている」と安心できます。

9-2. ボタン配置の最適化

一括操作バーのボタンが増えたため、モバイルでは2行レイアウトにしました:

  • モバイル:1行目に[★ 追加][☆ 解除]、2行目に[キャンセル][削除]
  • PC:1行に全ボタンを配置

また、通常表示では「追加」「解除」両方のボタンを表示し、お気に入り表示時は「解除」のみ表示するようにしました。

まとめ

作業マスタを導入したことで、日々の作業登録が格段に楽になりました。

目次