Skip to content

日時処理の統合とイベント終了日時の項目追加 #42

@oumelab

Description

@oumelab

イシュー: 日時処理の統合とイベント終了日時の項目追加

概要

複数のコンポーネントに散らばっている日時関連の処理を一箇所に集約し、システム全体の堅牢性と保守性を向上させる。これに伴い、将来のGoogleカレンダーとの連携機能実装の前提として、イベント終了日時を実装し、申し込み期限をイベント終了日時へ変更する。(現在はイベント開始日時までが申し込み期限)

対象ファイル

  • EventForm.tsx: フォームのUI、状態管理、バリデーション。
  • 日時フォーマットを変換する関数が記述されているすべてのファイル。
  • バックエンドのAPIロジック。
  • データベースのスキーマ - shared/schemas.ts
  • 型定義ファイル - shared/types.ts

タスクリスト

  1. バックエンドおよびデータベースの修正

    • events テーブルにend_dateカラムを追加する。
    • データベースの既存の日時データをXXXX年XX月XX日形式からYYYY-MM-DDTHH:mm形式に一括変換するスクリプトを準備する。
    • バックエンドのAPIが、YYYY-MM-DDTHH:mm形式の日時データを受け付けて保存・更新できるように修正する。
    • 申し込み期限ロジック拡張 - functions/api/events/[id]/apply.ts 修正
  2. 日時フォーマット変換関数の共通化

    • XXXX年XX月XX日形式とYYYY-MM-DDTHH:mm形式を相互に変換する関数を、utils/date.tsなどの共通ユーティリティファイルに集約する。
    • すべてのコンポーネントからこの共通関数を参照するように修正する。
  3. Zodスキーマの修正

    • CreateEventSchemaおよびUpdateEventSchemaに、end_dateフィールドを追加する。
    • dateend_dateのバリデーションを、YYYY-MM-DDTHH:mm形式の文字列として、今日以降の日付であることをチェックするようにrefineで修正する。
    • dateend_dateの論理的な整合性(例: 開催日時が終了日時よりも前であること)を検証するカスタムバリデーションを追加する。
  4. EventForm.tsxの日時管理ロジックの修正

    • useStateuseEffectによるlocalDateTimeの管理を廃止する。
    • react-hook-formuseWatchフックを使って、開催日時と終了日時のリアルタイムプレビューを実装する。
    • datetime-localinput要素を直接registerし、zodでバリデーションする。
    • EventFormにイベント終了日時(end_date)の入力項目を追加し、同様のロジックで管理する。
  5. その他フロントエンドの修正

    • イベント一覧の日付順の並び替え、イベント開催終了判定などのロジック修正 - src/hooks/useEventUtils.ts
    • イベント一覧の日付順表示のあるコンポーネント(トップページ、申し込み履歴、作成履歴など)

完了条件

  • EventFormで開始日時と終了日時が正しく入力・プレビュー・送信できること。
  • Zodスキーマのカスタムバリデーションが機能すること。
  • 日時フォーマット変換のロジックが共通化されていること。
  • データベースの日時データがYYYY-MM-DDTHH:mm形式に統一されていること。
  • 既存のイベント表示機能が問題なく動作すること。
  • 終了時間は開始時間より後のみ設定可能。
  • 終了時間まで申し込み可能であること。

実装案

eventsテーブルのdateのデータ形式を修正

バックアップの取得:

  • 万が一失敗した場合でも、元の状態に戻せるようにしておく。

変換スクリプトの作成とテスト:

  • XXXX年XX月XX日形式の文字列をYYYY-MM-DDTHH:mm形式に変換するSQLスクリプトを作成。
-- 例:
UPDATE events SET date = REPLACE(REPLACE(REPLACE(date, '', '-'), '', '-'), '', '') 
  • ✅上記のような文字列置換関数を使うか、より複雑なパースロジックをデータベースのプロシージャなどで実装する。
  • ✅開発環境またはステージング環境のデータベースで、必ず事前にこのスクリプトを実行し、期待通りに動作するか、データが壊れないかを入念にテスト。

データベーススキーマ変更

-- db/migrations/004_add_end_time.sql
BEGIN TRANSACTION;

-- eventsテーブルにend_dateカラムを追加
ALTER TABLE events ADD COLUMN end_date TEXT;

-- 既存データに終了時間を追加(開始から2時間後をデフォルト)
UPDATE events 
SET end_date = CASE 
  WHEN date LIKE '%17:00' THEN REPLACE(date, '17:00', '19:00')
  WHEN date LIKE '%19:00' THEN REPLACE(date, '19:00', '21:00')
  WHEN date LIKE '%16:00' THEN REPLACE(date, '16:00', '18:00')
  ELSE date -- デフォルトは開始時間と同じ
END
WHERE end_date IS NULL;

COMMIT;

型定義の拡張

// shared/types.ts
export interface Event {
  id: string;
  title: string;
  date: string;      // 開始日時(既存)
  end_date?: string; // 🆕 終了日時(オプション)
  location: string;
  description: string;
  image_url?: string;
  capacity?: number;
  created_at?: number;
  creator_id?: string | null;
}

// 時間関連のユーティリティ型
export interface EventTimeInfo {
  startDateTime: string;
  endDateTime?: string;
  duration?: number; // 分単位
  canApplyUntilEnd: boolean;
}

Zodスキーマの修正例

// z.string()で文字列であることを確認後、refineでカスタムバリデーション
date: z
    .string({ required_error: "開催日時は必須です" })
    .min(1, "開催日時は必須です")
    .refine(
        (value) => {
            // valueはYYYY-MM-DDTHH:mm形式の文字列
            const inputDate = new Date(value);
            // new Date()は不正な形式だと Invalid Date を返す
            return !isNaN(inputDate.getTime());
        },
        "開催日時の形式が不正です"
    )
    .refine(
        (value) => {
            const inputDate = new Date(value);
            const now = new Date();
            // 現在時刻と比較して、過去の日付でないことを確認
            // 分単位で比較することで、今より少しでも過去だと弾く
            return inputDate.getTime() >= now.setSeconds(0, 0); 
        },
        "過去の日時は選択できません"
    ),

Zodスキーマ拡張例

// shared/schemas.ts
export const CreateEventSchema = z.object({
  title: z.string().trim().min(1, 'タイトルは必須です').max(100),
  date: z.string().min(1, '開始日時は必須です'),
  end_date: z.string().optional(), // 🆕 終了日時(オプション)
  location: z.string().min(1, '開催場所は必須です').max(100),
  description: z.string().max(1000).optional(),
  image_url: z.string().url().optional().or(z.literal('')),
  capacity: z.number().int().min(1).optional(),
}).refine((data) => {
  // 🎯 終了時間が開始時間より後かチェック
  if (data.end_date && data.date) {
    const startTime = parseDateTimeString(data.date);
    const endTime = parseDateTimeString(data.end_date);
    return endTime > startTime;
  }
  return true;
}, {
  message: "終了時間は開始時間より後に設定してください",
  path: ["end_date"]
});

フォームUI実装例

// src/components/events/EventForm.tsx 拡張
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  {/* 開始日時 */}
  <div className="space-y-2">
    <label className="text-sm font-medium">
      開始日時 <span className="text-red-500">*</span>
    </label>
    <Input
      type="datetime-local"
      value={startDateTime}
      onChange={(e) => {
        setStartDateTime(e.target.value);
        form.setValue('date', formatToJapaneseDateTime(e.target.value));
        
        // 🎯 開始時間変更時に終了時間も自動調整(2時間後)
        if (e.target.value) {
          const endTime = addHours(new Date(e.target.value), 2);
          setEndDateTime(formatToInputDateTime(endTime));
          form.setValue('end_date', formatToJapaneseDateTime(endTime));
        }
      }}
    />
  </div>

  {/* 🆕 終了日時 */}
  <div className="space-y-2">
    <label className="text-sm font-medium">
      終了日時
      <span className="text-gray-500 text-xs ml-2">(省略可)</span>
    </label>
    <Input
      type="datetime-local"
      value={endDateTime}
      onChange={(e) => {
        setEndDateTime(e.target.value);
        if (e.target.value) {
          form.setValue('end_date', formatToJapaneseDateTime(e.target.value));
        } else {
          form.setValue('end_date', undefined);
        }
      }}
    />
    <p className="text-xs text-gray-500">
      終了時間まで申し込み可能になります
    </p>
  </div>
</div>

申し込み期限ロジック拡張

// functions/api/events/[id]/apply.ts 修正
function canApplyToEvent(event: Event): { canApply: boolean; reason?: string } {
  const now = new Date();
  
  // 🎯 終了時間が設定されている場合は終了時間まで申し込み可能
  const deadline = event.end_date 
    ? parseDateTimeString(event.end_date)
    : parseDateTimeString(event.date);
  
  if (deadline <= now) {
    const deadlineType = event.end_date ? "終了" : "開始";
    return {
      canApply: false,
      reason: `イベント${deadlineType}後の申し込みはできません`
    };
  }
  
  return { canApply: true };
}

useWatchを使った実装

EventForm.tsxをuseWatchで書き直す実装例

// EventForm.tsx

// ...
import { useForm, useWatch } from "react-hook-form";
// ...

export function EventForm(props: EventFormAllProps) {
  // ...
  const form = useForm<CreateEventRequest | UpdateEventRequest>({ /* ... */ });

  // 💡 コンポーネントのロジックからuseWatchを使用
  const localDateTime = useWatch({
    control: form.control,
    name: "date",
  });
  
  // 💡 watchした値を使って、プレビュー用のフォーマット済み文字列を生成
  const displayDate = localDateTime ? formatDateTime(localDateTime) : "";

  // ...handleSubmit, etc.

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 mt-2 mb-4">
        {/* ... */}
        
        <div className="space-y-1">
          {/* ...ラベルなど */}

          <Input
            type="datetime-local"
            // ...その他のprops
            {...form.register("date")} 
          />

          {/* 💡 useWatchの値から生成されたdisplayDateを表示 */}
          {displayDate && (
            <div className="mt-2 text-sm text-green-700 font-semibold">
              📅 プレビュー: {displayDate}
            </div>
          )}
          
          {/* ...メッセージなど */}
        </div>

        {/* ... */}
      </form>
    </Form>
  );
}
  • useWatchuseFormcontrolオブジェクトを必要とするが、これによってreact-hook-formのフォーム状態と連携
  • useWatchnameプロパティに指定されたフィールド(この場合はdate)の値が変更されたときにのみ、再レンダリングをトリガー
  • localDateTime変数は、dateフィールドの最新の値を常に保持
  • useEffectuseStateによる手動での同期ロジックを排除しつつ、パフォーマンス上の懸念も解消

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions