diff --git a/CLAUDE.md b/CLAUDE.md index 0564e1b..2d0f6b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,23 @@ import type { AppType } from "../../../../server/src/main"; const client = hc(API_ENDPOINT); ``` -REST fetch ではなく `client.projects[":projectId"].$get(...)` 形式で呼び出す。 +REST fetch ではなく `client.events[":eventId"].$get(...)` 形式で呼び出す。 + +### データモデル(D1) + +`server/src/db/schema.ts`。テーブル名は小文字の単数形・camelCase。 + +- `event`: 日程調整のイベント。`startDate` / `endDate` は `"YYYY-MM-DD"`(`endDate` を含む)、`timezone` は IANA のタイムゾーン名(現在は全件 `Asia/Tokyo`)。 +- `allowedRange`: 入力できる時間帯(現在はイベントにつき 1 つ)。`startTime` / `endTime` は `"HH:mm"`。終わりは `"24:00"` もありうる。 +- `option`: 参加形態。 +- `participant`: 参加者。`role` でホストとゲストを区別する。`name` が null なら未回答(イベントを作っただけのホスト)。ホストが回答しても 1 行のまま。 +- `slot`: 参加者が参加できる時間帯。`startAt` / `endAt` は UTC の ISO 8601 で、日をまたがない。 + +日付と時間帯は、イベントの `timezone` の壁時計の値で持つ。Slot だけは実際の時刻なので UTC で持つ。API も同じ形式で受け取って返す。 + +API のレスポンスでは、参加者を `hosts`(ホスト)、`guests` と `meAsGuest`(回答した参加者)に分けて返し、Slot の参加者 ID は `guestId` として返す。 + +マイグレーションは `server/migrations/`。データの変換を伴うものは `drizzle-kit generate --custom` で作って SQL を手で書き、`server/test/migrations.test.ts` に移行のテストを足す。D1 は外部キーを常に強制し、`DROP TABLE` の暗黙の DELETE で `ON DELETE CASCADE` が発火するので、他のテーブルから参照されるテーブルは作り直さない(`RENAME` や `ADD COLUMN` で変える)。 ### 認証(browserId) @@ -57,12 +73,13 @@ REST fetch ではなく `client.projects[":projectId"].$get(...)` 形式で呼 `client/src/lib/CalendarMatrix.ts` に核となるデータ構造がある。カレンダーの選択状態は 2D 行列で管理される(行=日、列=15分単位のスロット): -- **`EditingMatrix`**: 自分の入力中スロット。セル値は `participationOptionId`(文字列)。 +- **`EditingMatrix`**: 自分の入力中スロット。セル値は参加形態の id(文字列)。 - **`ViewingMatrix`**: 全ゲストの既存スロット。セル値は `{ [guestId]: optionId }` のレコード。 +- **`HighlightMatrix`**: 「最多人数」や特定のゲストの強調表示に使う、条件を満たすセル。 -どちらも `getSlots()` で連続するセルをまとめてイベント単位に変換(run-length encoding 的な処理)して返す。 +いずれも `getSlots()` で連続するセルをまとめて区間に変換(run-length encoding 的な処理)して返す。 -`Calendar.tsx` はこのマトリックスを `useRef` で保持し、スロット変更時に `editingSlots → matrix → CalendarEvent[]` の順で再計算して FullCalendar に渡す。 +`Calendar.tsx` は `EditingMatrix` を `useRef` で保持し、`editingSlots` が変わると行列を作り直して区間を再計算する。`ViewingMatrix` は `viewingSlots` から `useMemo` で作る。どちらの区間も、グリッドの上に絶対配置で直接描画する。区間の終わりは 24:00(翌日の 0:00)になりうるので、位置は開始からの経過時間で求める。 ### dayjs のインポート制限 @@ -75,10 +92,10 @@ import dayjs from "dayjs"; import dayjs from "../lib/dayjs"; ``` -### 参加形態(ParticipationOption)の流れ +### 参加形態(option)の流れ - 作成時: フロントエンドで `crypto.randomUUID()` を生成して `id` を確定。サーバーは受け取った id をそのまま使用。 -- デフォルト: 参加形態が0件の場合は `common/colors.ts` の `DEFAULT_PARTICIPATION_OPTION` を使用してデフォルトを自動作成(label: "参加", color: "#0F82B1")。 +- デフォルト: イベントの作成フォームの初期値に、`common/colors.ts` の `DEFAULT_PARTICIPATION_OPTION`(label: "参加", color: "#0F82B1")を 1 つ入れる。API は参加形態を最低 1 つ要求する。 - 削除制限: Slot が紐づいている参加形態は削除不可(サーバー側で検証)。 ### Cloudflare Pages Functions diff --git a/client/functions/[[path]].ts b/client/functions/[[path]].ts index 1d6397c..4031eb4 100644 --- a/client/functions/[[path]].ts +++ b/client/functions/[[path]].ts @@ -4,14 +4,14 @@ interface Env { API_ENDPOINT: string; } -interface ProjectData { +interface EventData { id: string; name: string; startDate: string; endDate: string; allowedRanges: Array<{ id: string; - projectId: string; + eventId: string; startTime: string; endTime: string; }>; @@ -71,16 +71,16 @@ function formatDate(dateString: string): string { .replace(/-/g, "/"); } -// プロジェクト情報を取得 -async function fetchProjectData(eventId: string, apiEndpoint: string): Promise { +// イベント情報を取得 +async function fetchEventData(eventId: string, apiEndpoint: string): Promise { try { - const response = await fetch(`${apiEndpoint}/projects/${eventId}`); + const response = await fetch(`${apiEndpoint}/events/${eventId}`); if (!response.ok) { return null; } return await response.json(); } catch (error) { - console.error("Failed to fetch project data:", error); + console.error("Failed to fetch event data:", error); return null; } } @@ -140,21 +140,21 @@ export async function onRequest(context: EventContext): Promise { +async function fetchEventData(eventId: string, apiEndpoint: string): Promise { try { - const response = await fetch(`${apiEndpoint}/projects/${eventId}`); + const response = await fetch(`${apiEndpoint}/events/${eventId}`); if (!response.ok) { return null; } return await response.json(); } catch (error) { - console.error("Failed to fetch project data:", error); + console.error("Failed to fetch event data:", error); return null; } } @@ -75,10 +75,10 @@ export const onRequestGet: PagesFunction = async (context) => { return new Response("Invalid event ID", { status: 400 }); } - const projectData = await fetchProjectData(eventId, env.API_ENDPOINT); + const eventData = await fetchEventData(eventId, env.API_ENDPOINT); - if (!projectData) { - // プロジェクトがない場合はデフォルト画像にリダイレクト + if (!eventData) { + // イベントがない場合はデフォルト画像にリダイレクト const url = new URL(context.request.url); return Response.redirect(`${url.origin}/og-image.jpg`, 302); } @@ -87,10 +87,10 @@ export const onRequestGet: PagesFunction = async (context) => { "https://unpkg.com/@fontsource/m-plus-1p@5.2.8/files/m-plus-1p-japanese-700-normal.woff", ).then((res) => res.arrayBuffer()); - const projectName = truncateByWidth(projectData.name, 72); + const eventName = truncateByWidth(eventData.name, 72); - const startDate = formatDate(projectData.startDate); - const endDate = formatDate(projectData.endDate); + const startDate = formatDate(eventData.startDate); + const endDate = formatDate(eventData.endDate); const dateRange = startDate === endDate ? startDate : `${startDate} 〜 ${endDate}`; const response = new ImageResponse( @@ -134,7 +134,7 @@ export const onRequestGet: PagesFunction = async (context) => { wordBreak: "break-word", }} > - {projectName} + {eventName} } /> } /> - } /> + } /> }> } /> - } /> + } /> } /> diff --git a/client/src/components/AddToCalendar.tsx b/client/src/components/AddToCalendar.tsx index 03ca72c..e5849ba 100644 --- a/client/src/components/AddToCalendar.tsx +++ b/client/src/components/AddToCalendar.tsx @@ -5,9 +5,9 @@ import { generateIcs } from "../lib/generateIcs"; import type { Slot } from "../types"; type Props = { - projectName: string; - projectId: string; - projectDescription?: string | null; + eventName: string; + eventId: string; + eventDescription?: string | null; slots: Pick[]; participationOptionIdToLabel: Record; // 参加形態がデフォルト値のみ(=実質未設定)かどうかの判定に使う @@ -20,17 +20,17 @@ type Props = { * カレンダーの縦幅を圧迫しないよう、タイトル行に並べて置く想定(モバイルではアイコンのみ)。 */ export function AddToCalendar({ - projectName, - projectId, - projectDescription, + eventName, + eventId, + eventDescription, slots, participationOptionIdToLabel, participationOptionCount, }: Props) { const handleClick = () => { - const eventUrl = `${window.location.origin}/e/${projectId}`; + const eventUrl = `${window.location.origin}/e/${eventId}`; const icsContent = generateIcs( - projectName, + eventName, slots.map((slot) => { const label = participationOptionIdToLabel[slot.participationOptionId] ?? ""; // 参加形態がデフォルト値のみで他に選択肢がない場合、タイトルには含めない @@ -42,7 +42,7 @@ export function AddToCalendar({ }; }), eventUrl, - projectDescription ?? undefined, + eventDescription ?? undefined, ); const blob = new Blob([icsContent], { type: "text/calendar;charset=utf-8" }); diff --git a/client/src/components/Calendar.tsx b/client/src/components/Calendar.tsx index 9251279..89429a7 100644 --- a/client/src/components/Calendar.tsx +++ b/client/src/components/Calendar.tsx @@ -5,9 +5,10 @@ import { EditingMatrix, ViewingMatrix } from "../lib/CalendarMatrix"; import type { Dayjs } from "../lib/dayjs"; import type { EditingSlot } from "../pages/eventId/Submission"; +/** 時刻は "HH:mm"。終わりは "24:00" もありうる */ type AllowedRange = { - startTime: Dayjs; - endTime: Dayjs; + startTime: string; + endTime: string; }; type ViewingSlot = { @@ -78,6 +79,12 @@ function hexToRgb(hex: string): [number, number, number] { : PRIMARY_RGB; } +/** "HH:mm" を 0 時からの経過分にする */ +function toMinutes(time: string): number { + const [hour, minute] = time.split(":").map(Number); + return hour * 60 + minute; +} + export const Calendar = ({ startDate, endDate, @@ -94,13 +101,11 @@ export const Calendar = ({ }: Props) => { const countDays = endDate.startOf("day").diff(startDate.startOf("day"), "day") + 1; - const allowedRange = allowedRanges[0] ?? { - startTime: startDate.startOf("day"), - endTime: startDate.startOf("day").add(23, "hour").add(59, "minute"), - }; - const slotStartMinutes = allowedRange.startTime.hour() * 60 + allowedRange.startTime.minute(); - const slotEndMinutes = allowedRange.endTime.hour() * 60 + allowedRange.endTime.minute(); - const slotCount = Math.ceil((slotEndMinutes - slotStartMinutes) / 15); + // 時間帯が無ければ終日とみなす + const allowedRange = allowedRanges[0] ?? { startTime: "00:00", endTime: "24:00" }; + const slotStartMinutes = toMinutes(allowedRange.startTime); + const slotEndMinutes = toMinutes(allowedRange.endTime); + const slotCount = (slotEndMinutes - slotStartMinutes) / 15; const gridHeight = slotCount * MIN_SLOT_HEIGHT; const innerWidth = TIME_COL_WIDTH + countDays * MIN_DAY_WIDTH; @@ -411,7 +416,8 @@ export const Calendar = ({ {computedViewingSlots.map((slot) => { const dayIdx = slot.from.startOf("day").diff(startDate.startOf("day"), "day"); const fromSlot = toSlotIdx(slot.from); - const toSlot = toSlotIdx(slot.to); + // to は 24:00(翌日 0:00)になりうるので、from からの経過時間で求める + const toSlot = fromSlot + slot.to.diff(slot.from, "minute") / 15; if (dayIdx < 0 || dayIdx >= countDays || fromSlot < 0 || toSlot <= fromSlot) return null; const optionGroups = new Map(); @@ -509,7 +515,8 @@ export const Calendar = ({ {slots.map((slot) => { const dayIdx = slot.from.startOf("day").diff(startDate.startOf("day"), "day"); const fromSlot = toSlotIdx(slot.from); - const toSlot = toSlotIdx(slot.to); + // to は 24:00(翌日 0:00)になりうるので、from からの経過時間で求める + const toSlot = fromSlot + slot.to.diff(slot.from, "minute") / 15; if (dayIdx < 0 || dayIdx >= countDays) return null; const option = participationOptions.find((o) => o.id === slot.optionId); diff --git a/client/src/lib/generateIcs.ts b/client/src/lib/generateIcs.ts index ff9fb94..a31e123 100644 --- a/client/src/lib/generateIcs.ts +++ b/client/src/lib/generateIcs.ts @@ -13,22 +13,22 @@ function toUtcDateArray(date: Dayjs): DateArray { } export function generateIcs( - projectName: string, + eventName: string, slots: SlotForIcs[], eventUrl?: string, - projectDescription?: string, + eventDescription?: string, ): string { const description = [ "イツヒマで提出した参加候補日程です。", eventUrl ? `イベントページ: ${eventUrl}` : null, - projectDescription ? `\n${projectDescription}` : null, + eventDescription ? `\n${eventDescription}` : null, ] .filter(Boolean) .join("\n"); const { error, value } = createEvents( slots.map((slot) => ({ - title: `【候補】${projectName}${slot.label ? `(${slot.label})` : ""} - イツヒマ`, + title: `【候補】${eventName}${slot.label ? `(${slot.label})` : ""} - イツヒマ`, description, start: toUtcDateArray(slot.from), startInputType: "utc", diff --git a/client/src/pages/Project.tsx b/client/src/pages/EventForm.tsx similarity index 81% rename from client/src/pages/Project.tsx rename to client/src/pages/EventForm.tsx index 498d491..4565ab6 100644 --- a/client/src/pages/Project.tsx +++ b/client/src/pages/EventForm.tsx @@ -17,38 +17,38 @@ import { import { NavLink, useNavigate, useParams } from "react-router"; import type { z } from "zod"; import { DEFAULT_PARTICIPATION_OPTION, generateDistinctColor } from "../../../common/colors"; -import { editReqSchema, projectReqSchema } from "../../../common/validators"; +import { editEventReqSchema, eventReqSchema } from "../../../common/validators"; import type { AppType } from "../../../server/src/main"; import Header from "../components/Header"; import { EXTERNAL_LINKS } from "../constants/links"; import dayjs from "../lib/dayjs"; -import { projectReviver } from "../revivers"; -import type { Project } from "../types"; +import { eventReviver } from "../revivers"; +import type { Event } from "../types"; import { API_ENDPOINT, FRONTEND_ORIGIN, readErrorMessage } from "../utils"; const client = hc(API_ENDPOINT); -export default function ProjectPage() { +export default function EventFormPage() { const { eventId } = useParams(); const navigate = useNavigate(); - const formSchema = eventId ? editReqSchema : projectReqSchema; + const formSchema = eventId ? editEventReqSchema : eventReqSchema; type FormSchemaType = z.infer; - const [project, setProject] = useState(null); - const [projectLoading, setProjectLoading] = useState(true); + const [event, setEvent] = useState(null); + const [eventLoading, setEventLoading] = useState(true); - const fetchProject = useCallback(async () => { + const fetchEvent = useCallback(async () => { if (!eventId) { - setProject(null); - setProjectLoading(false); + setEvent(null); + setEventLoading(false); return; } - setProjectLoading(true); + setEventLoading(true); try { - const res = await client.projects[":projectId"].$get( + const res = await client.events[":eventId"].$get( { - param: { projectId: eventId }, + param: { eventId }, }, { init: { credentials: "include" }, @@ -56,10 +56,10 @@ export default function ProjectPage() { ); if (res.status === 200) { const data = await res.json(); - const parsedData = projectReviver(data); - setProject(parsedData); + const parsedData = eventReviver(data); + setEvent(parsedData); } else { - const errorMessage = await readErrorMessage(res, "プロジェクトの取得に失敗しました。"); + const errorMessage = await readErrorMessage(res, "イベントの取得に失敗しました。"); setToast({ message: errorMessage, variant: "error", @@ -74,20 +74,20 @@ export default function ProjectPage() { }); setTimeout(() => setToast(null), 5000); } finally { - setProjectLoading(false); + setEventLoading(false); } }, [eventId]); useEffect(() => { - fetchProject(); - }, [fetchProject]); + fetchEvent(); + }, [fetchEvent]); const [submitLoading, setSubmitLoading] = useState(false); - const loading = projectLoading || submitLoading; + const loading = eventLoading || submitLoading; const [dialogStatus, setDialogStatus] = useState<{ - projectId: string; - projectName: string; + eventId: string; + eventName: string; } | null>(null); // TODO: グローバルにしないと、delete の際は遷移を伴うので表示されない @@ -117,7 +117,7 @@ export default function ProjectPage() { startDate: eventId ? "" : dayjs.tz().format("YYYY-MM-DD"), endDate: eventId ? "" : dayjs.tz().add(6, "day").format("YYYY-MM-DD"), allowedRanges: [{ startTime: "08:00", endTime: "23:00" }], - participationOptions: eventId + options: eventId ? [] : [ { @@ -144,71 +144,49 @@ export default function ProjectPage() { remove: removeParticipation, } = useFieldArray({ control, - keyName: "fieldId", // RHF 内部のキーの名称。デフォルトの id だと participationOptions の id と衝突するため変更 - name: "participationOptions", + keyName: "fieldId", // RHF 内部のキーの名称。デフォルトの id だと options の id と衝突するため変更 + name: "options", }); useEffect(() => { if (!eventId) return; - if (!project) return; + if (!event) return; + const range = event.allowedRanges[0]; reset({ - name: project.name, - description: project.description, - startDate: project.startDate.tz().format("YYYY-MM-DD"), - endDate: project.endDate.tz().format("YYYY-MM-DD"), + name: event.name, + description: event.description, + startDate: event.startDate.format("YYYY-MM-DD"), + endDate: event.endDate.format("YYYY-MM-DD"), allowedRanges: [ - { - startTime: project.allowedRanges[0].startTime.tz().format("HH:mm"), - endTime: project.allowedRanges[0].endTime.tz().format("HH:mm"), - }, + // 時間帯が無いイベントは終日とみなす + range ? { startTime: range.startTime, endTime: range.endTime } : { startTime: "00:00", endTime: "24:00" }, ], - participationOptions: project.participationOptions.map((opt) => ({ + options: event.options.map((opt) => ({ id: opt.id, label: opt.label, color: opt.color, })), }); - }, [eventId, project, reset]); + }, [eventId, event, reset]); // 送信処理 const onSubmit = async (data: FormSchemaType) => { setSubmitLoading(true); - const startDtISO = dayjs.tz(data.startDate).startOf("day").toISOString(); - const endDtISO = dayjs.tz(data.endDate).endOf("day").toISOString(); - - const rangeWithDateTime = data.allowedRanges?.map((range) => { - // 注: 現在のところ日付部分は使用していない。 - const startTime = dayjs - .tz(data.startDate) - .hour(Number(range.startTime.split(":")[0])) - .minute(Number(range.startTime.split(":")[1])) - .second(0); - const endTime = dayjs - .tz(data.endDate) - .hour(Number(range.endTime.split(":")[0])) - .minute(Number(range.endTime.split(":")[1])) - .second(0); - return { - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - }; - }); - const eventData = { name: data.name ?? "", description: data.description ?? "", - startDate: startDtISO, - endDate: endDtISO, - allowedRanges: rangeWithDateTime ?? [], - participationOptions: (data.participationOptions ?? []).map((opt) => ({ + startDate: data.startDate ?? "", + endDate: data.endDate ?? "", + allowedRanges: data.allowedRanges ?? [], + options: (data.options ?? []).map((opt) => ({ id: opt.id, label: opt.label.trim(), color: opt.color, })), - } satisfies z.infer; + } satisfies z.infer; - if (!project) { - const res = await client.projects.$post( + if (!event) { + const res = await client.events.$post( { json: eventData, }, @@ -221,8 +199,8 @@ export default function ProjectPage() { if (res.status === 201) { const { id, name } = await res.json(); setDialogStatus({ - projectId: id, - projectName: name, + eventId: id, + eventName: name, }); } else { setToast({ @@ -232,14 +210,14 @@ export default function ProjectPage() { setTimeout(() => setToast(null), 3000); } } else { - const res = await client.projects[":projectId"].$put( - { param: { projectId: project.id }, json: eventData }, + const res = await client.events[":eventId"].$patch( + { param: { eventId: event.id }, json: eventData }, { init: { credentials: "include" } }, ); setSubmitLoading(false); if (res.ok) { // TODO: PUT のレスポンスでデータを返すことを検討 - await fetchProject(); + await fetchEvent(); setToast({ message: "更新しました。", variant: "success", @@ -259,17 +237,17 @@ export default function ProjectPage() { } }; - const isHost = project?.isHost; + const isHost = event?.isHost; useEffect(() => { - if (!loading && project && !isHost) { + if (!loading && event && !isHost) { if (eventId) { navigate(`/e/${eventId}`); } else { navigate("/"); } } - }, [loading, project, isHost, eventId, navigate]); + }, [loading, event, isHost, eventId, navigate]); return ( <> @@ -279,7 +257,7 @@ export default function ProjectPage() {
- ) : eventId !== undefined && !project ? ( + ) : eventId !== undefined && !event ? (

イベントが見つかりませんでした。

@@ -291,12 +269,10 @@ export default function ProjectPage() { ) : (
-

- {project ? `${project.name} の編集` : "新規作成"} -

+

{event ? `${event.name} の編集` : "新規作成"}

-
+ {/* 基本情報 */}

基本情報

@@ -337,9 +313,9 @@ export default function ProjectPage() {

日程の範囲

0 ? "tooltip tooltip-top" : ""} + className={event && event.guests.length > 0 ? "tooltip tooltip-top" : ""} data-tip={ - project && project.guests.length > 0 + event && event.guests.length > 0 ? "すでに日程を登録したユーザーがいるため、開始日の編集はできません" : "" } @@ -351,16 +327,16 @@ export default function ProjectPage() { type="date" {...register("startDate")} id="input-start" - className={`w-full rounded-lg border px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5 ${errors.startDate ? "border-red-500" : "border-base-300"} ${project && project.guests.length > 0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} + className={`w-full rounded-lg border px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5 ${errors.startDate ? "border-red-500" : "border-base-300"} ${event && event.guests.length > 0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} onFocus={handleFieldFocus} - disabled={!!(project && project.guests.length > 0)} + disabled={!!(event && event.guests.length > 0)} /> {errors.startDate &&

{errors.startDate.message}

}
0 ? "tooltip tooltip-top" : ""} + className={event && event.guests.length > 0 ? "tooltip tooltip-top" : ""} data-tip={ - project && project.guests.length > 0 + event && event.guests.length > 0 ? "すでに日程を登録したユーザーがいるため、終了日の編集はできません" : "" } @@ -372,9 +348,9 @@ export default function ProjectPage() { type="date" {...register("endDate")} id="input-end" - className={`w-full rounded-lg border px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5 ${errors.endDate ? "border-red-500" : "border-base-300"} ${project && project.guests.length > 0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} + className={`w-full rounded-lg border px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5 ${errors.endDate ? "border-red-500" : "border-base-300"} ${event && event.guests.length > 0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} onFocus={handleFieldFocus} - disabled={!!(project && project.guests.length > 0)} + disabled={!!(event && event.guests.length > 0)} /> {errors.endDate &&

{errors.endDate.message}

}
@@ -395,9 +371,9 @@ export default function ProjectPage() {
0 ? "tooltip tooltip-top w-full" : "w-full"} + className={event && event.guests.length > 0 ? "tooltip tooltip-top w-full" : "w-full"} data-tip={ - project && project.guests.length > 0 + event && event.guests.length > 0 ? "すでに日程を登録したユーザーがいるため、時間帯の編集はできません" : "" } @@ -407,7 +383,7 @@ export default function ProjectPage() {
0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} + className={`flex-1 rounded-lg border px-2 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-3 sm:py-2.5 ${errors.allowedRanges ? "border-red-500" : "border-base-300"} ${event && event.guests.length > 0 ? "cursor-not-allowed bg-base-200 opacity-60" : ""}`} value={allowedRangeFields[0].startTime.split(":")[1]} onChange={(e) => { replace([ @@ -443,7 +419,7 @@ export default function ProjectPage() { ]); }} onFocus={handleFieldFocus} - disabled={!!(project && project.guests.length > 0)} + disabled={!!(event && event.guests.length > 0)} >
@@ -559,7 +539,7 @@ export default function ProjectPage() {
{participationFields.map((field, index) => { - const hasSlots = project?.guests.some((guest) => + const hasSlots = event?.guests.some((guest) => guest.slots.some((slot) => slot.participationOptionId === field.id), ); const isLastOption = participationFields.length === 1; @@ -572,11 +552,11 @@ export default function ProjectPage() { return (
- +
{ - trigger(`participationOptions.${index}.label` as const); + trigger(`options.${index}.label` as const); }} />
@@ -604,14 +584,14 @@ export default function ProjectPage() {
- {errors.participationOptions?.[index]?.label && ( + {errors.options?.[index]?.label && (

- {errors.participationOptions[index]?.label?.message as string} + {errors.options[index]?.label?.message as string}

)} - {errors.participationOptions?.[index]?.color && ( + {errors.options?.[index]?.color && (

- {errors.participationOptions[index]?.color?.message as string} + {errors.options[index]?.color?.message as string}

)}
@@ -638,7 +618,7 @@ export default function ProjectPage() {
{/* イベントの削除 */} - {project && ( + {event && (

イベントの削除

@@ -670,8 +650,8 @@ export default function ProjectPage() { キャンセル )} -

@@ -684,7 +664,7 @@ export default function ProjectPage() {

- {dialogStatus.projectName}を作成しました + {dialogStatus.eventName}を作成しました

URL をコピーして共有しましょう!

@@ -692,12 +672,12 @@ export default function ProjectPage() { type="text" disabled className="h-10 min-w-0 flex-1 rounded-lg border border-base-300 bg-base-200 px-3 text-base text-base-content/80" - value={`${FRONTEND_ORIGIN}/e/${dialogStatus.projectId}`} + value={`${FRONTEND_ORIGIN}/e/${dialogStatus.eventId}`} />
- + イベントへ @@ -723,7 +703,7 @@ export default function ProjectPage() { )} {/* 削除確認ダイアログ */} - {project && ( + {event && (

イベントを削除しますか?

@@ -738,8 +718,8 @@ export default function ProjectPage() { onClick={async () => { deleteDialogRef.current?.close(); try { - const res = await client.projects[":projectId"].$delete( - { param: { projectId: project.id } }, + const res = await client.events[":eventId"].$delete( + { param: { eventId: event.id } }, { init: { credentials: "include" } }, ); if (!res.ok) { diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 52700f3..00a995a 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -5,14 +5,14 @@ import { NavLink } from "react-router"; import type { AppType } from "../../../server/src/main"; import Footer from "../components/Footer"; import Header from "../components/Header"; -import { briefProjectReviver } from "../revivers"; -import type { BriefProject } from "../types"; +import { briefEventReviver } from "../revivers"; +import type { BriefEvent } from "../types"; import { API_ENDPOINT, readErrorMessage } from "../utils"; const client = hc(API_ENDPOINT); export default function HomePage() { - const [involvedProjects, setInvolvedProjects] = useState(null); + const [involvedEvents, setInvolvedEvents] = useState(null); const [loading, setLoading] = useState(true); const [toast, setToast] = useState<{ message: string; @@ -20,14 +20,14 @@ export default function HomePage() { } | null>(null); useEffect(() => { - const fetchInvolvedProjects = async () => { + const fetchInvolvedEvents = async () => { setLoading(true); try { - const res = await client.projects.mine.$get({}, { init: { credentials: "include" } }); + const res = await client.events.mine.$get({}, { init: { credentials: "include" } }); if (res.status === 200) { const data = await res.json(); - const parsedData = data.map((p) => briefProjectReviver(p)); - setInvolvedProjects(parsedData); + const parsedData = data.map((p) => briefEventReviver(p)); + setInvolvedEvents(parsedData); } else { const errorMessage = await readErrorMessage(res, "イベントの取得に失敗しました。"); setToast({ @@ -35,21 +35,21 @@ export default function HomePage() { variant: "error", }); setTimeout(() => setToast(null), 5000); - setInvolvedProjects(null); + setInvolvedEvents(null); } } catch (error) { - console.error("Error fetching involved projects:", error); + console.error("Error fetching involved events:", error); setToast({ message: "ネットワークエラーが発生しました。", variant: "error", }); setTimeout(() => setToast(null), 5000); - setInvolvedProjects(null); + setInvolvedEvents(null); } finally { setLoading(false); } }; - fetchInvolvedProjects(); + fetchInvolvedEvents(); }, []); return ( @@ -67,9 +67,9 @@ export default function HomePage() {
{loading ? ( - - ) : involvedProjects && involvedProjects.length > 0 ? ( - + + ) : involvedEvents && involvedEvents.length > 0 ? ( + ) : ( )} @@ -95,67 +95,67 @@ export default function HomePage() { ); } -function ProjectDashboard({ involvedProjects }: { involvedProjects: BriefProject[] }) { - const hostedProjects = involvedProjects +function EventDashboard({ involvedEvents }: { involvedEvents: BriefEvent[] }) { + const hostedEvents = involvedEvents .filter((p) => p.isHost) .sort((a, b) => b.startDate.valueOf() - a.startDate.valueOf()); - const participatingProjects = involvedProjects + const participatingEvents = involvedEvents .filter((p) => !p.isHost) .sort((a, b) => b.startDate.valueOf() - a.startDate.valueOf()); return (
- {hostedProjects.length > 0 && ( - } projects={hostedProjects} /> + {hostedEvents.length > 0 && ( + } events={hostedEvents} /> )} - {participatingProjects.length > 0 && ( - 0 && ( + } - projects={participatingProjects} + events={participatingEvents} /> )}
); } -function ProjectSection({ title, icon, projects }: { title: string; icon: React.ReactNode; projects: BriefProject[] }) { +function EventSection({ title, icon, events }: { title: string; icon: React.ReactNode; events: BriefEvent[] }) { return (
{icon}

{title}

- {projects.length} + {events.length}
- {projects.map((project, index) => ( - + {events.map((event, index) => ( + ))}
); } -function ProjectRow({ project, isLast }: { project: BriefProject; isLast: boolean }) { +function EventRow({ event, isLast }: { event: BriefEvent; isLast: boolean }) { return (

- {project.name} + {event.name}

- {project.startDate.format("YYYY年M月D日")} 〜 {project.endDate.format("YYYY年M月D日")} + {event.startDate.format("YYYY年M月D日")} 〜 {event.endDate.format("YYYY年M月D日")}
@@ -176,7 +176,7 @@ function EmptyState() { ); } -function ProjectsSkeleton() { +function EventsSkeleton() { return (
{[1, 2].map((section) => ( diff --git a/client/src/pages/eventId/Submission.tsx b/client/src/pages/eventId/Submission.tsx index ae99d81..49cdc79 100644 --- a/client/src/pages/eventId/Submission.tsx +++ b/client/src/pages/eventId/Submission.tsx @@ -19,8 +19,8 @@ import type { AppType } from "../../../../server/src/main"; import { AddToCalendar } from "../../components/AddToCalendar"; import { Calendar, type Highlight } from "../../components/Calendar"; import Header from "../../components/Header"; -import { projectReviver } from "../../revivers"; -import type { Project, Slot } from "../../types"; +import { eventReviver } from "../../revivers"; +import type { Event, Slot } from "../../types"; import { API_ENDPOINT, readErrorMessage } from "../../utils"; const client = hc(API_ENDPOINT); @@ -74,25 +74,25 @@ const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => { }; export default function SubmissionPage() { - const { eventId: projectId } = useParams<{ eventId: string }>(); - const [project, setProject] = useState(null); - const [projectLoading, setProjectLoading] = useState(true); + const { eventId } = useParams<{ eventId: string }>(); + const [event, setEvent] = useState(null); + const [eventLoading, setEventLoading] = useState(true); const [postLoading, setPostLoading] = useState(false); - const loading = projectLoading || postLoading; + const loading = eventLoading || postLoading; - const fetchProject = useCallback(async () => { - if (!projectId) { - setProject(null); - setProjectLoading(false); + const fetchEvent = useCallback(async () => { + if (!eventId) { + setEvent(null); + setEventLoading(false); return; } - setProjectLoading(true); + setEventLoading(true); try { - const res = await client.projects[":projectId"].$get( + const res = await client.events[":eventId"].$get( { - param: { projectId }, + param: { eventId }, }, { init: { credentials: "include" }, @@ -100,10 +100,10 @@ export default function SubmissionPage() { ); if (res.status === 200) { const data = await res.json(); - const parsedData = projectReviver(data); - setProject(parsedData); + const parsedData = eventReviver(data); + setEvent(parsedData); } else { - const errorMessage = await readErrorMessage(res, "プロジェクトの取得に失敗しました。"); + const errorMessage = await readErrorMessage(res, "イベントの取得に失敗しました。"); setToast({ message: errorMessage, variant: "error", @@ -111,23 +111,23 @@ export default function SubmissionPage() { setTimeout(() => setToast(null), 5000); } } catch (error) { - console.error("Error fetching project:", error); + console.error("Error fetching event:", error); setToast({ message: "ネットワークエラーが発生しました。", variant: "error", }); setTimeout(() => setToast(null), 5000); } finally { - setProjectLoading(false); + setEventLoading(false); } - }, [projectId]); + }, [eventId]); useEffect(() => { - fetchProject(); - }, [fetchProject]); + fetchEvent(); + }, [fetchEvent]); - const meAsGuest = project?.meAsGuest; + const meAsGuest = event?.meAsGuest; const myGuestId = meAsGuest?.id; - const isHost = project?.isHost; + const isHost = event?.isHost; const [mode, setMode] = useState<"view" | "edit" | "confirm">("view"); @@ -155,17 +155,16 @@ export default function SubmissionPage() { const payload = { name: guestName, comment: comment.trim() || null, - projectId: projectId || "", slots: slots.map((slot) => ({ - start: slot.start.toISOString(), - end: slot.end.toISOString(), - participationOptionId: slot.participationOptionId, + startAt: slot.start.toISOString(), + endAt: slot.end.toISOString(), + optionId: slot.participationOptionId, })), }; if (!myGuestId) { - const response = await client.projects[":projectId"].submissions.$post( + const response = await client.events[":eventId"].submissions.$post( { - param: { projectId: projectId || "" }, + param: { eventId: eventId || "" }, json: payload, }, { @@ -186,9 +185,9 @@ export default function SubmissionPage() { setTimeout(() => setToast(null), 3000); } } else { - const response = await client.projects[":projectId"].submissions.mine.$put( + const response = await client.events[":eventId"].submissions.mine.$put( { - param: { projectId: projectId || "" }, + param: { eventId: eventId || "" }, json: payload, }, { @@ -209,10 +208,10 @@ export default function SubmissionPage() { setTimeout(() => setToast(null), 3000); } } - await Promise.all([fetchProject()]); + await Promise.all([fetchEvent()]); setPostLoading(false); }, - [guestName, comment, projectId, fetchProject], + [guestName, comment, eventId, fetchEvent], ); useEffect(() => { @@ -230,26 +229,26 @@ export default function SubmissionPage() { }, [mode]); const guestIdToName = useMemo(() => { - if (!project) return {}; - return Object.fromEntries(project.guests.map((g) => [g.id, g.name])); - }, [project]); + if (!event) return {}; + return Object.fromEntries(event.guests.map((g) => [g.id, g.name])); + }, [event]); const guestIdToComment = useMemo(() => { - if (!project) return {}; - return Object.fromEntries(project.guests.filter((g) => g.comment).map((g) => [g.id, g.comment as string])); - }, [project]); + if (!event) return {}; + return Object.fromEntries(event.guests.filter((g) => g.comment).map((g) => [g.id, g.comment as string])); + }, [event]); const participationOptionIdToLabel = useMemo(() => { - if (!project) return {}; - return Object.fromEntries(project.participationOptions.map((opt) => [opt.id, opt.label])); - }, [project]); + if (!event) return {}; + return Object.fromEntries(event.options.map((opt) => [opt.id, opt.label])); + }, [event]); const viewingSlots = useMemo(() => { - if (!project) return []; + if (!event) return []; if (mode !== "view") { // 編集・確認モードの場合、自分のスロットは editingSlots に入るので、こちらには自分以外のスロットのみ含める - return project.guests + return event.guests .filter((g) => g.id !== myGuestId) .flatMap((g) => g.slots.map((s) => ({ @@ -262,7 +261,7 @@ export default function SubmissionPage() { } // 閲覧モードの場合は自分も含めて全て - return project.guests.flatMap((g) => + return event.guests.flatMap((g) => g.slots.map((s) => ({ from: s.from, to: s.to, @@ -270,14 +269,14 @@ export default function SubmissionPage() { optionId: s.participationOptionId, })), ); - }, [project, myGuestId, mode]); + }, [event, myGuestId, mode]); - // project が読み込まれたらデフォルトの参加形態を設定 + // event が読み込まれたらデフォルトの参加形態を設定 useEffect(() => { - if (project && project.participationOptions.length > 0 && !selectedParticipationOptionId) { - setSelectedParticipationOptionId(project.participationOptions[0].id); + if (event && event.options.length > 0 && !selectedParticipationOptionId) { + setSelectedParticipationOptionId(event.options[0].id); } - }, [project, selectedParticipationOptionId]); + }, [event, selectedParticipationOptionId]); return ( <> @@ -287,7 +286,7 @@ export default function SubmissionPage() {
- ) : !project ? ( + ) : !event ? (

イベントが見つかりませんでした。

@@ -303,37 +302,37 @@ export default function SubmissionPage() { ) : (
- {/* プロジェクト情報 */} + {/* イベント情報 */}
-

{project.name}

+

{event.name}

{/* 自分の日程をカレンダーアプリに追加 */} {mode === "view" && meAsGuest && meAsGuest.slots.length > 0 && ( )} {isHost && ( - + 編集 )}
- {project.description && + {event.description && (() => { - const { text: truncatedText, truncated } = truncateText(project.description); + const { text: truncatedText, truncated } = truncateText(event.description); return (

- {descriptionExpanded ? project.description : truncatedText} + {descriptionExpanded ? event.description : truncatedText}

{truncated && ( {/* ハイライト操作(カレンダーの縦幅を使わないようこの行に置く) */} @@ -453,7 +452,7 @@ export default function SubmissionPage() {
{guestListExpanded && (
    - {project.guests.map((guest) => { + {event.guests.map((guest) => { const commentText = guestIdToComment[guest.id]; const isHighlighted = highlight?.type === "guest" && highlight.guestId === guest.id; return ( @@ -552,7 +551,7 @@ export default function SubmissionPage() { )}
- {project && mode === "confirm" && ( + {event && mode === "confirm" && ( <>