-
Notifications
You must be signed in to change notification settings - Fork 1
Open
Labels
Description
イシュー: 日時処理の統合とイベント終了日時の項目追加
概要
複数のコンポーネントに散らばっている日時関連の処理を一箇所に集約し、システム全体の堅牢性と保守性を向上させる。これに伴い、将来のGoogleカレンダーとの連携機能実装の前提として、イベント終了日時を実装し、申し込み期限をイベント終了日時へ変更する。(現在はイベント開始日時までが申し込み期限)
対象ファイル
EventForm.tsx
: フォームのUI、状態管理、バリデーション。- 日時フォーマットを変換する関数が記述されているすべてのファイル。
- バックエンドのAPIロジック。
- データベースのスキーマ -
shared/schemas.ts
- 型定義ファイル -
shared/types.ts
タスクリスト
-
バックエンドおよびデータベースの修正
events
テーブルにend_date
カラムを追加する。- データベースの既存の日時データを
XXXX年XX月XX日
形式からYYYY-MM-DDTHH:mm
形式に一括変換するスクリプトを準備する。 - バックエンドのAPIが、
YYYY-MM-DDTHH:mm
形式の日時データを受け付けて保存・更新できるように修正する。 - 申し込み期限ロジック拡張 -
functions/api/events/[id]/apply.ts
修正
-
日時フォーマット変換関数の共通化
XXXX年XX月XX日
形式とYYYY-MM-DDTHH:mm
形式を相互に変換する関数を、utils/date.ts
などの共通ユーティリティファイルに集約する。- すべてのコンポーネントからこの共通関数を参照するように修正する。
-
Zodスキーマの修正
CreateEventSchema
およびUpdateEventSchema
に、end_date
フィールドを追加する。date
とend_date
のバリデーションを、YYYY-MM-DDTHH:mm
形式の文字列として、今日以降の日付であることをチェックするようにrefine
で修正する。date
とend_date
の論理的な整合性(例: 開催日時が終了日時よりも前であること)を検証するカスタムバリデーションを追加する。
-
EventForm.tsx
の日時管理ロジックの修正useState
とuseEffect
によるlocalDateTime
の管理を廃止する。react-hook-form
のuseWatch
フックを使って、開催日時と終了日時のリアルタイムプレビューを実装する。datetime-local
のinput
要素を直接register
し、zod
でバリデーションする。EventForm
にイベント終了日時(end_date
)の入力項目を追加し、同様のロジックで管理する。
-
その他フロントエンドの修正
- イベント一覧の日付順の並び替え、イベント開催終了判定などのロジック修正 -
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>
);
}
- ✅
useWatch
はuseForm
のcontrol
オブジェクトを必要とするが、これによってreact-hook-form
のフォーム状態と連携 - ✅
useWatch
はname
プロパティに指定されたフィールド(この場合はdate
)の値が変更されたときにのみ、再レンダリングをトリガー - ✅
localDateTime
変数は、date
フィールドの最新の値を常に保持 - ✅
useEffect
やuseState
による手動での同期ロジックを排除しつつ、パフォーマンス上の懸念も解消