栽培管理アプリを作ってます⑧:作業履歴の検索機能を強化した話

目次

はじめに

前回の記事では、作業履歴画面で肥料・農薬の詳細を表示できるようにしました。
今回は、作業履歴の検索・フィルタ機能を大幅に強化した話をお届けします。

特に考えたのはカレンダー機能の実装です。実は出荷アプリと栽培アプリで異なるアプローチを採用しています。

今回実装した機能:

  • 折りたたみ式カレンダー表示
  • 作業種類をボタン式フィルタに変更
  • カレンダーとフィルタの連動

カレンダー実装の2つのアプローチ

私は現在、出荷管理アプリ栽培管理アプリの2つを開発しています。どちらもカレンダー機能が必要でしたが、用途に応じて実装方法を変えました。

出荷アプリ:<input type="date"> を採用

出荷アプリでは、HTML標準の<input type="date">を採用しました。

用途

出荷予定日を入力するためのシンプルなカレンダー

実装コード

<input type="date" />

選んだ理由

  1. 日付の入力だけが目的
  2. ブラウザ標準機能で十分
  3. 実装コストがほぼゼロ
  4. モバイル対応も自動

メリット

  • ✅ ライブラリ不要
  • ✅ 実装が超簡単(1行で完結)
  • ✅ OS標準のUIで使いやすい
  • ✅ 入力バリデーションも自動
  • ✅ アクセシビリティ対応済み

デメリット

  • ❌ 見た目のカスタマイズができない
  • ❌ 複雑な機能は追加できない
  • ❌ 表示だけの用途には向かない
  • ❌ ブラウザによって見た目が異なる

栽培アプリ:完全手作りカレンダー

栽培アプリでは、ライブラリを使わず完全手作りでカレンダーを実装しました。

用途

作業履歴を検索・フィルタリングするための表示用カレンダー

手作りにした理由

  1. 日付を表示して検索したい
  2. 作業があった日に●マークを表示したい
  3. フィルタと連動させたい
  4. デザインを完全にコントロールしたい

実装の核心部分

// 月の日数を計算
const daysInMonth = new Date(year, month + 1, 0).getDate();

// 月初の曜日を計算(0=日曜、6=土曜)
const firstDay = new Date(year, month, 1).getDay();

// 7列のグリッドで表示
<div style={{ 
  display: "grid", 
  gridTemplateColumns: "repeat(7, 1fr)",
  gap: 4
}}>
  {/* 曜日ヘッダー */}
  {["日", "月", "火", "水", "木", "金", "土"].map(day => (
    <div key={day}>{day}</div>
  ))}

  {/* 空白セル(月初まで) */}
  {Array.from({ length: firstDay }).map((_, i) => (
    <div key={`empty-${i}`} />
  ))}

  {/* 日付セル */}
  {Array.from({ length: daysInMonth }).map((_, i) => {
    const day = i + 1;
    const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
    const hasWork = workDates.has(dateStr);

    return (
      <div key={day} onClick={() => handleDateClick(dateStr)}>
        {day}
        {hasWork && <span>●</span>}
      </div>
    );
  })}
</div>

メリット

  • ✅ 完全にカスタマイズ可能
  • ✅ 複雑な機能も実装できる
  • ✅ フィルタとの連動が自由
  • ✅ デザインを完全にコントロール
  • ✅ 作業日の可視化が可能

デメリット

  • ❌ 実装コストが高い
  • ❌ Date計算のロジックが必要
  • ❌ バグの可能性がある
  • ❌ メンテナンスコストがかかる

栽培アプリで実装した機能

栽培アプリで実装した具体的な機能です。

1. 折りたたみ式カレンダー

仕様

  • 初期状態は閉じている
  • 「カレンダーを表示」ボタンで展開
  • 月の切り替え(◀ ▶ ボタン)
  • 作業があった日に緑の●マーク
  • 日付クリックでその日の作業だけ表示

2. 作業種類をボタン式に変更

Before:プルダウン選択

<select value={filterLogType}>
  <option value="">すべて</option>
  <option value="灌水">灌水</option>
  <option value="施肥">施肥</option>
  <option value="防除">防除</option>
</select>

After:ボタン式(トグル)

<button onClick={() => handleToggle("灌水")}>
  灌水
</button>
<button onClick={() => handleToggle("施肥")}>
  施肥
</button>
<button onClick={() => handleToggle("防除")}>
  防除
</button>
<button onClick={() => handleToggle("その他")}>
  その他
</button>

改善ポイント

  • ✅ ワンクリックでフィルタ適用
  • ✅ 視認性が向上(アイコン付き)
  • ✅ トグル式で直感的
  • ✅ 選択状態が色で分かりやすい

トグルロジック

const handleLogTypeToggle = (type: string) => {
  if (filterLogType === type) {
    setFilterLogType(null);  // 同じボタン → 解除
  } else {
    setFilterLogType(type);  // 別のボタン → 適用
  }
};

3. カレンダーとフィルタの連動

【画像挿入位置】フィルタ連動のデモGIF(灌水ボタン押すと●が変わる)

仕様

  • 灌水ボタンを押す → 灌水した日だけ●が表示
  • ハウスを選択 → そのハウスで作業した日だけ●が表示
  • 複数フィルタの組み合わせが可能
  • 例:「No.1ハウス」+「灌水」= No.1で灌水した日だけ●

実装の核心

// フィルタ適用後のログから作業日を集計
const workDates = useMemo(() => {
  const dates = new Set<string>();

  logs.forEach(log => {
    // ハウスフィルタ
    if (filterFieldId && log.cult_fields.id !== filterFieldId) return;

    // 畝フィルタ
    if (filterBedId && log.cult_beds.id !== filterBedId) return;

    // 作業種類フィルタ
    if (filterLogType && log.log_type !== filterLogType) return;

    // 日付フィルタは除外(カレンダー表示用)
    // if (selectedDate && log.log_date !== selectedDate) return;

    // 条件を満たした日付を追加
    dates.add(log.log_date);
  });

  return dates;
}, [logs, filterFieldId, filterBedId, filterLogType]);

ポイント

  • useMemoで効率的に計算
  • フィルタ変更時に自動で再計算
  • 日付フィルタは除外(選択中でも全ての作業日を表示)

工夫した点

1. 選択中でも他の作業日の●を表示

当初の仕様:

  • 14日を選択 → 14日以外の●が消える

問題点:

  • どの日に作業があるか分からなくなる
  • 別の日を選びたいときに不便

改善後:

  • 14日を選択 → 他の作業日も●が表示されたまま
  • 選択中の日は緑色の背景で強調

実装の変更点:

// Before(日付フィルタを含めて集計)
const workDates = useMemo(() => {
  return filteredLogs.map(log => log.log_date);
}, [filteredLogs]);

// After(日付フィルタを除外して集計)
const workDates = useMemo(() => {
  const dates = new Set<string>();

  logs.forEach(log => {
    if (filterFieldId && log.cult_fields.id !== filterFieldId) return;
    if (filterLogType && log.log_type !== filterLogType) return;
    // selectedDate は除外!

    dates.add(log.log_date);
  });

  return dates;
}, [logs, filterFieldId, filterLogType]);  // selectedDate を依存配列から除外

2. 日付の直接切り替え

当初の仕様:

  • 14日を選択 → 16日を選びたい
  • 手順:14日をクリックして解除 → 16日をクリック(2回)

改善後:

  • 14日を選択 → 16日をクリック(1回で切り替え)

実装:

const handleDateClick = (dateStr: string) => {
  if (selectedDate === dateStr) {
    setSelectedDate(null);  // 同じ日付 → 解除
  } else {
    setSelectedDate(dateStr);  // 別の日付 → 直接切り替え
  }
};

シンプルですが、UXが大きく向上します!


まとめ

今回は作業履歴の検索機能を大幅に強化しました。

実装した機能:

  • ✅ 折りたたみ式カレンダー
  • ✅ ボタン式フィルタ
  • ✅ カレンダーとフィルタの連動
  • ✅ 日付選択機能

カレンダー実装の使い分け:

  • 入力用<input type="date">(出荷アプリ)
  • 表示・検索用 → 手作りカレンダー(栽培アプリ)

手作りカレンダーはこれが正解かどうか、ということもありますが、
しばらく実装してみて様子を見たいと思います。


目次