diff --git a/client/src/components/Calendar.tsx b/client/src/components/Calendar.tsx index 9d8eab4..7e40117 100644 --- a/client/src/components/Calendar.tsx +++ b/client/src/components/Calendar.tsx @@ -8,6 +8,7 @@ import type { DateSpanApi, DayHeaderContentArg, EventContentArg, + EventInput, EventMountArg, SlotLabelContentArg, } from "@fullcalendar/core/index.js"; @@ -27,7 +28,29 @@ type AllowedRange = { type ViewingSlot = { from: Date; to: Date; - guestName: string; + guestId: string; + optionId: string; +}; + +type ParticipationOption = { + id: string; + label: string; + color: string; +}; + +type CalendarEventExtendedProps = { + optionBreakdown?: { + optionId: string; + optionLabel: string; + color: string; + members: string[]; + count: number; + }[]; + backgroundStyle?: string; +}; + +type CalendarEvent = Pick & { + extendedProps?: CalendarEventExtendedProps; }; type Props = { @@ -36,6 +59,9 @@ type Props = { allowedRanges: AllowedRange[]; editingSlots: EditingSlot[]; viewingSlots: ViewingSlot[]; + guestIdToName: Record; + participationOptions: ParticipationOption[]; + currentParticipationOptionId: string; editMode: boolean; onChangeEditingSlots: (slots: EditingSlot[]) => void; }; @@ -49,12 +75,23 @@ const SELECT_EVENT = "ih-select-event"; const CREATE_SELECT_EVENT = "ih-create-select-event"; const DELETE_SELECT_EVENT = "ih-delete-select-event"; +// TODO: colors.ts のものと共通化 +function hexToRgb(hex: string): [number, number, number] { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? [Number.parseInt(result[1], 16), Number.parseInt(result[2], 16), Number.parseInt(result[3], 16)] + : (PRIMARY_RGB as [number, number, number]); +} + export const Calendar = ({ startDate, endDate, allowedRanges, editingSlots, viewingSlots, + guestIdToName, + participationOptions, + currentParticipationOptionId, editMode, onChangeEditingSlots, }: Props) => { @@ -72,19 +109,8 @@ export const Calendar = ({ const calendarRef = useRef(null); const isSelectionDeleting = useRef(null); - // matrix → FullCalendar 用イベント(宣言的に管理) - const [events, setEvents] = useState< - Array<{ - id: string; - className: string; - start: Date; - end: Date; - textColor?: string; - color?: string; - display?: "background"; - extendedProps?: { members?: string[]; countMembers?: number }; - }> - >([]); + // FullCalendar の state + const [events, setEvents] = useState([]); // editingSlots/viewingSlots → matrix → events useEffect(() => { @@ -92,40 +118,143 @@ export const Calendar = ({ editingMatrixRef.current.clear(); editingSlots.forEach((slot) => { const { from, to } = getVertexes(slot.from, slot.to); - editingMatrixRef.current.setRange(from, to, 1); + editingMatrixRef.current.setRange(from, to, slot.participationOptionId); }); - // viewingSlots → viewingMatrix viewingMatrixRef.current.clear(); + viewingSlots.forEach((slot) => { const { from, to } = getVertexes(slot.from, slot.to); - viewingMatrixRef.current.incrementRange(from, to, slot.guestName); + viewingMatrixRef.current.setGuestRange(from, to, slot.guestId, slot.optionId); }); // matrix → events - const editingEvents = editingMatrixRef.current.getSlots().map((slot, index) => ({ - id: `${EDITING_EVENT}-${index}`, - className: EDITING_EVENT, - start: slot.from, - end: slot.to, - textColor: "black", - })); - - const viewingEvents = viewingMatrixRef.current.getSlots().map((slot, index) => ({ - id: `${VIEWING_EVENT}-${index}`, - className: VIEWING_EVENT, - start: slot.from, - end: slot.to, - color: `rgba(${PRIMARY_RGB.join(",")}, ${(1 - (1 - OPACITY) ** slot.weight).toFixed(3)})`, - display: "background" as const, - extendedProps: { - members: slot.guestNames, - countMembers: slot.weight, - }, - })); + const editingEvents = editingMatrixRef.current.getSlots().map((slot, index) => { + const option = participationOptions.find((o) => o.id === slot.optionId); + const baseColor = option ? option.color : `rgb(${PRIMARY_RGB.join(",")})`; + const rgbColor = hexToRgb(baseColor); + const backgroundColor = `rgba(${rgbColor.join(",")}, ${OPACITY})`; + + return { + id: `${EDITING_EVENT}-${index}`, + className: EDITING_EVENT, + start: slot.from, + end: slot.to, + textColor: "white", + backgroundColor, + borderColor: baseColor, + }; + }); + + const viewingEvents: CalendarEvent[] = []; + const slots = viewingMatrixRef.current.getSlots(); + + slots.forEach((slot, index) => { + // optionId ごとにグループ化 + const optionGroups = new Map(); + + for (const [guestId, optionId] of Object.entries(slot.guestIdToOptionId)) { + if (!optionGroups.has(optionId)) { + optionGroups.set(optionId, []); + } + optionGroups.get(optionId)?.push(guestId); + } + + // 参加形態ごとの内訳を作成。順番を participationOptions に合わせる + const optionBreakdown = participationOptions + .filter((option) => optionGroups.has(option.id)) + .map((option) => { + const guestIds = optionGroups.get(option.id) || []; + const guestNames = guestIds.map((guestId) => guestIdToName[guestId] || guestId); + const optionOpacity = 1 - (1 - OPACITY) ** guestIds.length; + + return { + optionId: option.id, + optionLabel: option.label, + color: option.color, + members: guestNames, + count: guestIds.length, + opacity: optionOpacity, + }; + }); + + // 複数の参加形態がある場合は複合、 そうでなければ単色 + let backgroundStyle: string; + if (optionBreakdown.length === 1) { + const rgbColor = hexToRgb(optionBreakdown[0].color); + backgroundStyle = `rgba(${rgbColor.join(",")}, ${optionBreakdown[0].opacity.toFixed(3)})`; + } else { + // 複数色の入ったセルを CSS gradient で生成(各optionの濃さを個別に適用) + const stripeWidth = 100 / optionBreakdown.length; + const gradientStops = optionBreakdown + .map((breakdown, i) => { + const rgbColor = hexToRgb(breakdown.color); + const start = i * stripeWidth; + const end = (i + 1) * stripeWidth; + return `rgba(${rgbColor.join(",")}, ${breakdown.opacity.toFixed(3)}) ${start}%, rgba(${rgbColor.join(",")}, ${breakdown.opacity.toFixed(3)}) ${end}%`; + }) + .join(", "); + backgroundStyle = `linear-gradient(90deg, ${gradientStops})`; + } + + // デフォルトの色(最初の参加形態の色) + const defaultColor = + optionBreakdown.length > 0 + ? (() => { + const rgbColor = hexToRgb(optionBreakdown[0].color); + return `rgba(${rgbColor.join(",")}, ${optionBreakdown[0].opacity.toFixed(3)})`; + })() + : `rgba(${PRIMARY_RGB.join(",")}, ${(1 - (1 - OPACITY) ** 1).toFixed(3)})`; + + viewingEvents.push({ + id: `${VIEWING_EVENT}-${index}`, + className: `${VIEWING_EVENT} ${VIEWING_EVENT}-${index}`, + start: slot.from, + end: slot.to, + color: defaultColor, + display: "background" as const, + extendedProps: { + optionBreakdown, + backgroundStyle, + }, + }); + }); setEvents([...editingEvents, ...viewingEvents]); - }, [editingSlots, viewingSlots]); + }, [editingSlots, viewingSlots, guestIdToName, participationOptions]); + + // viewing events の背景スタイルを動的に注入 + useEffect(() => { + const styleId = "ih-viewing-events-styles"; + let styleElement = document.getElementById(styleId) as HTMLStyleElement | null; + + if (!styleElement) { + styleElement = document.createElement("style"); + styleElement.id = styleId; + document.head.appendChild(styleElement); + } + + // viewing events の背景スタイルを生成 + const cssRules = events + .filter((event) => event.className?.includes(VIEWING_EVENT)) + .map((event) => { + if (!event.id) return ""; + const backgroundStyle = event.extendedProps?.backgroundStyle; + const eventIndex = event.id.replace(`${VIEWING_EVENT}-`, ""); + if (backgroundStyle) { + return `.${VIEWING_EVENT}-${eventIndex} { background: ${backgroundStyle} !important; }`; + } + return ""; + }) + .filter(Boolean) + .join("\n"); + + styleElement.textContent = cssRules; + + return () => { + // クリーンアップは不要(次回の更新で上書きされるため) + }; + }, [events]); // カレンダー外までドラッグした際に選択を解除するためのイベントハンドラを登録 useEffect(() => { @@ -199,9 +328,16 @@ export const Calendar = ({ // 選択中に選択範囲を表示する (info: DateSpanApi) => { if (!editMode) return false; - return displaySelection(info, isSelectionDeleting, calendarRef, editingMatrixRef); + return displaySelection( + info, + isSelectionDeleting, + calendarRef, + editingMatrixRef, + participationOptions, + currentParticipationOptionId, + ); }, - [editMode], + [editMode, participationOptions, currentParticipationOptionId], ); const handleSelect = useCallback( @@ -215,12 +351,13 @@ export const Calendar = ({ const isDeletion = isSelectionDeleting.current; // matrix を更新 - editingMatrixRef.current.setRange(from, to, isDeletion ? 0 : 1); + editingMatrixRef.current.setRange(from, to, isDeletion ? null : currentParticipationOptionId); // matrix → editingSlots const newSlots = editingMatrixRef.current.getSlots().map((slot) => ({ from: slot.from, to: slot.to, + participationOptionId: slot.optionId, })); onChangeEditingSlots(newSlots); @@ -232,28 +369,77 @@ export const Calendar = ({ } isSelectionDeleting.current = null; }, - [editMode, onChangeEditingSlots], + [editMode, onChangeEditingSlots, currentParticipationOptionId], ); const handleEventDidMount = useCallback((info: EventMountArg) => { if (info.event.classNames.includes(EDITING_EVENT)) { // 既存の event 上で選択できるようにするため。 info.el.style.pointerEvents = "none"; + + const borderColor = info.event.borderColor; + if (borderColor) { + info.el.style.borderColor = borderColor; + } + } + if (info.event.classNames.includes(VIEWING_EVENT)) { + const backgroundStyle = info.event.extendedProps.backgroundStyle; + if (backgroundStyle) { + info.el.style.background = backgroundStyle; + } + } + if (info.event.classNames.includes(CREATE_SELECT_EVENT)) { + const borderColor = info.event.borderColor; + if (borderColor) { + info.el.style.borderColor = borderColor; + } } }, []); const handleEventContent = useCallback((info: EventContentArg) => { if (info.event.classNames.includes(VIEWING_EVENT)) { + const optionBreakdown: { + optionId: string; + optionLabel: string; + color: string; + members: string[]; + count: number; + }[] = info.event.extendedProps.optionBreakdown || []; + return ( -
-
- {info.event.extendedProps.countMembers} -
+
+ {optionBreakdown.map((breakdown, index) => { + const tooltipContent = ` +
+
${breakdown.optionLabel}
+
    + ${breakdown.members.map((name) => `
  • ${name}
  • `).join("")} +
+
+ `; + const position = ((index + 0.5) / optionBreakdown.length) * 100; + + return ( +
+
+ {breakdown.count} +
+
+ ); + })}
); } @@ -286,7 +472,14 @@ export const Calendar = ({ eventDidMount={handleEventDidMount} eventContent={handleEventContent} /> - +
); }; @@ -296,6 +489,8 @@ function displaySelection( isSelectionDeleting: React.RefObject, calendarRef: React.RefObject, myMatrixRef: React.RefObject, + participationOptions: ParticipationOption[], + currentParticipationOptionId: string, ) { // 選択範囲の表示 // 通常の selection では矩形選択ができないため、イベントを作成することで選択範囲を表現している。 @@ -333,6 +528,16 @@ function displaySelection( [startTime, endTime] = [endTime, startTime]; } + // 現在選択されている参加形態の色を取得 + const currentOption = participationOptions.find((o) => o.id === currentParticipationOptionId); + const baseColor = currentOption ? currentOption.color : `rgb(${PRIMARY_RGB.join(",")})`; + + // 削除モードの場合は赤色、追加モードの場合は参加形態の色 + const isDeletion = isSelectionDeleting.current; + const rgbColor = isDeletion ? [255, 0, 0] : hexToRgb(baseColor); + const backgroundColor = `rgba(${rgbColor.join(",")}, ${isDeletion ? 0.5 : OPACITY})`; + const borderColor = isDeletion ? "red" : baseColor; + calendarApi.addEvent({ id: SELECT_EVENT, className: isSelectionDeleting.current ? DELETE_SELECT_EVENT : CREATE_SELECT_EVENT, @@ -341,6 +546,8 @@ function displaySelection( startRecur: info.start, endRecur: info.end, display: "background", + backgroundColor: backgroundColor, + borderColor: borderColor, }); return true; } diff --git a/client/src/index.css b/client/src/index.css index 3f01223..4f1b301 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -41,21 +41,22 @@ /* 作成の選択範囲 */ .ih-create-select-event { z-index: 100 !important; - background-color: var(--ih-color-primary-transparent) !important; - border: 3px dashed var(--color-primary) !important; + border-width: 1px !important; + border-style: dashed !important; } /* 削除の選択範囲 */ .ih-delete-select-event { z-index: 100 !important; - background-color: rgb(255, 0, 0, 0.5) !important; - border: 3px dashed red !important; + border-width: 1px !important; + border-style: dashed !important; + border-color: red !important; } /* 編集中のイベント */ .ih-editing-event { - border: 3px solid var(--color-primary) !important; - background-color: var(--ih-color-primary-transparent) !important; + border-width: 1px !important; + border-style: solid !important; } /* 閲覧中のイベント */ diff --git a/client/src/lib/CalendarMatrix.ts b/client/src/lib/CalendarMatrix.ts index 974e997..040e4ad 100644 --- a/client/src/lib/CalendarMatrix.ts +++ b/client/src/lib/CalendarMatrix.ts @@ -6,21 +6,20 @@ dayjs.locale("ja"); export type EditingMatrixSlot = { from: Date; to: Date; - weight: number; + optionId: string; }; export type ViewingMatrixSlot = { from: Date; to: Date; - weight: number; - guestNames: string[]; + guestIdToOptionId: Record; }; /** * イベントのマージ計算用に、カレンダーを2次元行列で管理 */ -abstract class CalendarMatrixBase { - protected matrix: number[][]; +abstract class CalendarMatrixBase { + protected matrix: (T | null)[][]; /** * 15 分を 1 セルとしたセルの数 (96 = 24 * 4) */ @@ -28,7 +27,7 @@ abstract class CalendarMatrixBase { protected initialDate: Dayjs; constructor(dayCount: number, initialDate: Date) { - this.matrix = Array.from({ length: dayCount }, () => Array.from({ length: this.quarterCount }, () => 0)); + this.matrix = Array.from({ length: dayCount }, () => Array.from({ length: this.quarterCount }, () => null)); this.initialDate = dayjs(initialDate).startOf("day"); } @@ -40,18 +39,10 @@ abstract class CalendarMatrixBase { getIsSlotExist(date: Date): boolean { const [row, col] = this.getIndex(date); - return this.matrix[row][col] !== 0; + return this.matrix[row][col] !== null; } - abstract getSlots(): EditingMatrixSlot[] | ViewingMatrixSlot[]; - abstract clear(): void; -} - -/** - * 編集中イベントの {@link CalendarMatrixBase} - */ -export class EditingMatrix extends CalendarMatrixBase { - setRange(from: Date, to: Date, newValue: number): void { + setRange(from: Date, to: Date, newValue: T | null): void { const [startRow, startCol] = this.getIndex(from); const [endRow, endCol] = this.getIndex(dayjs(to).subtract(1, "minute").toDate()); for (let r = startRow; r <= endRow; r++) { @@ -61,6 +52,18 @@ export class EditingMatrix extends CalendarMatrixBase { } } + clear(): void { + this.matrix = Array.from({ length: this.matrix.length }, () => + Array.from({ length: this.quarterCount }, () => null), + ); + } + abstract getSlots(): EditingMatrixSlot[] | ViewingMatrixSlot[]; +} + +/** + * 編集中イベントの {@link CalendarMatrixBase} + */ +export class EditingMatrix extends CalendarMatrixBase { getSlots(): EditingMatrixSlot[] { const slots: EditingMatrixSlot[] = []; for (let day = 0; day < this.matrix.length; day++) { @@ -70,49 +73,35 @@ export class EditingMatrix extends CalendarMatrixBase { return slots; } - private convertRunsToSlots(runs: { start: number; end: number; value: number }[], day: number): EditingMatrixSlot[] { - return ( - runs - // TODO: 値は null か非 null かで管理するようにしたい - .filter((run) => run.value !== 0) - .map((run) => { - const from = this.initialDate - .add(day, "day") - .add(run.start * 15, "minute") - .toDate(); - const to = this.initialDate - .add(day, "day") - .add(run.end * 15, "minute") - .toDate(); - const weight = run.value; - return { from, to, weight }; - }) - ); - } - - clear(): void { - this.matrix = Array.from({ length: this.matrix.length }, () => Array.from({ length: this.quarterCount }, () => 0)); + private convertRunsToSlots(runs: { start: number; end: number; value: string }[], day: number): EditingMatrixSlot[] { + return runs.map((run) => { + const from = this.initialDate + .add(day, "day") + .add(run.start * 15, "minute") + .toDate(); + const to = this.initialDate + .add(day, "day") + .add(run.end * 15, "minute") + .toDate(); + const optionId = run.value; + return { from, to, optionId }; + }); } } /** * 閲覧中イベントの {@link CalendarMatrixBase} */ -export class ViewingMatrix extends CalendarMatrixBase { - private guestNames: string[][][]; - - constructor(dayCount: number, initialDate: Date) { - super(dayCount, initialDate); - this.guestNames = Array.from({ length: dayCount }, () => Array.from({ length: this.quarterCount }, () => [])); - } - - incrementRange(from: Date, to: Date, guestName: string): void { +export class ViewingMatrix extends CalendarMatrixBase> { + setGuestRange(from: Date, to: Date, guestId: string, optionId: string): void { const [startRow, startCol] = this.getIndex(from); const [endRow, endCol] = this.getIndex(dayjs(to).subtract(1, "minute").toDate()); for (let r = startRow; r <= endRow; r++) { for (let c = startCol; c <= endCol; c++) { - this.matrix[r][c] += 1; - this.guestNames[r][c].push(guestName); + if (this.matrix[r][c] === null) { + this.matrix[r][c] = {}; + } + (this.matrix[r][c] as Record)[guestId] = optionId; } } } @@ -120,39 +109,39 @@ export class ViewingMatrix extends CalendarMatrixBase { getSlots(): ViewingMatrixSlot[] { const slots: ViewingMatrixSlot[] = []; for (let day = 0; day < this.matrix.length; day++) { - const runs = findRuns(this.matrix[day], (a, b) => a === b); + const runs = findRuns(this.matrix[day], (a, b) => isSameRecordShallow(a, b)); slots.push(...this.convertRunsToSlots(runs, day)); } return slots; } - private convertRunsToSlots(runs: { start: number; end: number; value: number }[], day: number): ViewingMatrixSlot[] { - return ( - runs - // TODO: 値は null か非 null かで管理するようにしたい - .filter((run) => run.value !== 0) - .map((run) => { - const from = this.initialDate - .add(day, "day") - .add(run.start * 15, "minute") - .toDate(); - const to = this.initialDate - .add(day, "day") - .add(run.end * 15, "minute") - .toDate(); - const weight = run.value; - const guestNames = this.guestNames[day][run.start]; - return { from, to, weight, guestNames }; - }) - ); + private convertRunsToSlots( + runs: { start: number; end: number; value: Record }[], + day: number, + ): ViewingMatrixSlot[] { + return runs.map((run) => { + const from = this.initialDate + .add(day, "day") + .add(run.start * 15, "minute") + .toDate(); + const to = this.initialDate + .add(day, "day") + .add(run.end * 15, "minute") + .toDate(); + const guestIdToOptionId = run.value; + return { from, to, guestIdToOptionId }; + }); } +} - clear(): void { - this.matrix = Array.from({ length: this.matrix.length }, () => Array.from({ length: this.quarterCount }, () => 0)); - this.guestNames = Array.from({ length: this.matrix.length }, () => - Array.from({ length: this.quarterCount }, () => []), - ); +function isSameRecordShallow(a: Record, b: Record): boolean { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + if (a[key] !== b[key]) return false; } + return true; } /** diff --git a/client/src/pages/Project.tsx b/client/src/pages/Project.tsx index 491c903..c6ac92e 100644 --- a/client/src/pages/Project.tsx +++ b/client/src/pages/Project.tsx @@ -1,7 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import dayjs from "dayjs"; import { hc } from "hono/client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { HiClipboardCheck, @@ -12,6 +12,7 @@ import { } from "react-icons/hi"; import { NavLink, useNavigate, useParams } from "react-router"; import type { z } from "zod"; +import { generateDistinctColor } from "../../../common/colors"; import { editReqSchema, projectReqSchema } from "../../../common/validators"; import type { AppType } from "../../../server/src/main"; import Header from "../components/Header"; @@ -30,37 +31,39 @@ export default function ProjectPage() { const [project, setProject] = useState(null); const [projectLoading, setProjectLoading] = useState(true); - useEffect(() => { - const fetchProject = async () => { - if (!eventId) { - setProject(null); - setProjectLoading(false); - return; - } - setProjectLoading(true); - try { - const res = await client.projects[":projectId"].$get( - { - param: { projectId: eventId }, - }, - { - init: { credentials: "include" }, - }, - ); - if (res.status === 200) { - const data = await res.json(); - const parsedData = projectReviver(data); - setProject(parsedData); - } - } catch (error) { - console.error(error); - } finally { - setProjectLoading(false); + + const fetchProject = useCallback(async () => { + if (!eventId) { + setProject(null); + setProjectLoading(false); + return; + } + setProjectLoading(true); + try { + const res = await client.projects[":projectId"].$get( + { + param: { projectId: eventId }, + }, + { + init: { credentials: "include" }, + }, + ); + if (res.status === 200) { + const data = await res.json(); + const parsedData = projectReviver(data); + setProject(parsedData); } - }; - fetchProject(); + } catch (error) { + console.error(error); + } finally { + setProjectLoading(false); + } }, [eventId]); + useEffect(() => { + fetchProject(); + }, [fetchProject]); + const [submitLoading, setSubmitLoading] = useState(false); const loading = projectLoading || submitLoading; @@ -77,6 +80,11 @@ export default function ProjectPage() { const [copied, setCopied] = useState(false); const [isInfoExpanded, setIsInfoExpanded] = useState(!eventId); // 新規作成時は展開、編集時は折りたたみ + const [participationOptions, setParticipationOptions] = useState<{ id: string; label: string; color: string }[]>([]); + const [initialParticipationOptions, setInitialParticipationOptions] = useState< + { id: string; label: string; color: string }[] + >([]); + const { register, handleSubmit, @@ -120,6 +128,14 @@ export default function ProjectPage() { }, ], }); + // 参加形態の初期化 + const initialOptions = project.participationOptions.map((opt) => ({ + id: opt.id, + label: opt.label, + color: opt.color, + })); + setParticipationOptions(initialOptions); + setInitialParticipationOptions(initialOptions); }, [eventId, project, reset]); // 送信処理 @@ -142,6 +158,13 @@ export default function ProjectPage() { startDate: startDateTime, endDate: endDateTime, allowedRanges: rangeWithDateTime ?? [], + participationOptions: participationOptions + .filter((opt) => opt.label.trim()) // 空のラベルは除外 + .map((opt) => ({ + id: opt.id, + label: opt.label.trim(), + color: opt.color, + })), } satisfies z.infer; if (!project) { @@ -176,7 +199,8 @@ export default function ProjectPage() { ); setSubmitLoading(false); if (res.ok) { - // TODO: 更新したデータで再レンダリング + // TODO: PUT のレスポンスでデータを返すことを検討 + await fetchProject(); setToast({ message: "更新しました。", variant: "success", @@ -204,6 +228,33 @@ export default function ProjectPage() { } }, [loading, project, isHost, eventId, navigate]); + // 参加形態の変更を検知 TODO: 実装の改善、rhf での管理 + const hasParticipationOptionsChanged = useMemo(() => { + if (!eventId) return false; // 新規作成の場合は参加形態の変更を検知しない + + // 数が違う場合 + if (participationOptions.length !== initialParticipationOptions.length) return true; + + // 各要素を比較 + for (let i = 0; i < participationOptions.length; i++) { + const current = participationOptions[i]; + const initial = initialParticipationOptions.find((opt) => opt.id === current.id); + + // IDが見つからない(新規追加された) + if (!initial) return true; + + // label または color が変更された + if (current.label !== initial.label || current.color !== initial.color) return true; + } + + // 削除された要素がないかチェック + for (const initial of initialParticipationOptions) { + if (!participationOptions.find((opt) => opt.id === initial.id)) return true; + } + + return false; + }, [participationOptions, initialParticipationOptions, eventId]); + return ( <>
@@ -415,6 +466,66 @@ export default function ProjectPage() { ) : (

すでにデータを登録したユーザーがいるため、日時の編集はできません。

)} +
+ 参加形態(任意) +

+ 参加形態を設定すると、参加者は「対面」「オンライン」などの形態を選んで日程を登録できます。 + 設定しない場合は、デフォルトの参加形態が自動的に作成されます。 +

+ + {participationOptions.map((option, index) => ( +
+ { + const newOptions = [...participationOptions]; + newOptions[index].color = e.target.value; + setParticipationOptions(newOptions); + }} + className="h-10 w-10 cursor-pointer rounded border-0" + /> + { + const newOptions = [...participationOptions]; + newOptions[index].label = e.target.value; + setParticipationOptions(newOptions); + }} + placeholder="参加形態名(例:対面、オンライン)" + className="input input-bordered flex-1 text-base" + /> + +
+ ))} + + +
{project && (
イベントの削除 @@ -463,7 +574,11 @@ export default function ProjectPage() { ホームに戻る -
diff --git a/client/src/pages/eventId/Submission.tsx b/client/src/pages/eventId/Submission.tsx index 1f9a9c2..d09e118 100644 --- a/client/src/pages/eventId/Submission.tsx +++ b/client/src/pages/eventId/Submission.tsx @@ -17,7 +17,7 @@ import { API_ENDPOINT } from "../../utils"; const client = hc(API_ENDPOINT); -export type EditingSlot = Pick; +export type EditingSlot = Pick; export default function SubmissionPage() { const { eventId: projectId } = useParams<{ eventId: string }>(); @@ -69,13 +69,15 @@ export default function SubmissionPage() { const [editingSlots, setEditingSlots] = useState([]); + const [selectedParticipationOptionId, setSelectedParticipationOptionId] = useState(null); + const [toast, setToast] = useState<{ message: string; variant: "success" | "error"; } | null>(null); const postSubmissions = useCallback( - async (slots: { start: Date; end: Date }[], myGuestId: string) => { + async (slots: { start: Date; end: Date; participationOptionId: string }[], myGuestId: string) => { setPostLoading(true); const payload = { name: guestName, @@ -83,6 +85,7 @@ export default function SubmissionPage() { slots: slots.map((slot) => ({ start: slot.start.toISOString(), end: slot.end.toISOString(), + participationOptionId: slot.participationOptionId, })), }; if (!myGuestId) { @@ -148,15 +151,15 @@ export default function SubmissionPage() { // init editing slots useEffect(() => { if (project?.meAsGuest?.slots && editMode) { - setEditingSlots( - project.meAsGuest.slots.map((slot) => ({ - from: slot.from, - to: slot.to, - })), - ); + setEditingSlots(project.meAsGuest.slots); } }, [project, editMode]); + const guestIdToName = useMemo(() => { + if (!project) return {}; + return Object.fromEntries(project.guests.map((g) => [g.id, g.name])); + }, [project]); + // init viewing slots const viewingSlots = useMemo(() => { if (!project) return []; @@ -169,7 +172,8 @@ export default function SubmissionPage() { g.slots.map((s) => ({ from: s.from, to: s.to, - guestName: g.name, + guestId: g.id, + optionId: s.participationOptionId, })), ); } @@ -179,16 +183,23 @@ export default function SubmissionPage() { g.slots.map((s) => ({ from: s.from, to: s.to, - guestName: g.name, + guestId: g.id, + optionId: s.participationOptionId, })), ); }, [project, myGuestId, editMode]); + // project が読み込まれたらデフォルトの参加形態を設定 + useEffect(() => { + if (project && project.participationOptions.length > 0 && !selectedParticipationOptionId) { + setSelectedParticipationOptionId(project.participationOptions[0].id); + } + }, [project, selectedParticipationOptionId]); return ( <>
- {loading ? ( + {loading || !selectedParticipationOptionId ? (
@@ -213,12 +224,32 @@ export default function SubmissionPage() { {project.description && (

{project.description}

)} + + {editMode && project.participationOptions.length > 1 && selectedParticipationOptionId !== null && ( +
+ 参加形態を選択 + +
+ )} @@ -255,9 +286,11 @@ export default function SubmissionPage() { onClick={() => { if (!guestName) return; postSubmissions( - editingSlots.map((slot) => { - return { start: slot.from, end: slot.to }; - }), + editingSlots.map((slot) => ({ + start: slot.from, + end: slot.to, + participationOptionId: slot.participationOptionId, + })), myGuestId ?? "", ); }} diff --git a/client/src/revivers.ts b/client/src/revivers.ts index 987f845..a852863 100644 --- a/client/src/revivers.ts +++ b/client/src/revivers.ts @@ -16,6 +16,7 @@ export function projectReviver(project: ISOStringProject): Project { startTime: new Date(range.startTime), endTime: new Date(range.endTime), })), + participationOptions: project.participationOptions.map((opt) => ({ ...opt })), hosts: project.hosts.map((host) => ({ ...host })), guests: project.guests.map((guest) => ({ ...guest, diff --git a/client/src/types.ts b/client/src/types.ts index a55d5e9..132771e 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -1,9 +1,17 @@ +type ParticipationOption = { + id: string; + label: string; + color: string; + projectId: string; +}; + export type Slot = { id: string; projectId: string; guestId: string; from: Date; to: Date; + participationOptionId: string; }; type AllowedRange = { @@ -32,6 +40,7 @@ export type Project = { startDate: Date; endDate: Date; allowedRanges: AllowedRange[]; + participationOptions: ParticipationOption[]; hosts: Host[]; guests: Guest[]; isHost: boolean; @@ -51,6 +60,12 @@ export type ISOStringProject = { startTime: string; endTime: string; }[]; + participationOptions: { + id: string; + label: string; + color: string; + projectId: string; + }[]; hosts: Host[]; guests: { id: string; @@ -62,6 +77,7 @@ export type ISOStringProject = { guestId: string; from: string; to: string; + participationOptionId: string; }[]; }[]; isHost: boolean; @@ -75,6 +91,7 @@ export type ISOStringProject = { guestId: string; from: string; to: string; + participationOptionId: string; }[]; } | null; }; diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json index 6f1747b..c047d17 100644 --- a/client/tsconfig.app.json +++ b/client/tsconfig.app.json @@ -18,7 +18,8 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "noImplicitOverride": true }, "include": ["src"] } diff --git a/common/colors.ts b/common/colors.ts new file mode 100644 index 0000000..eff2ec5 --- /dev/null +++ b/common/colors.ts @@ -0,0 +1,79 @@ +export const PREDEFINED_COLORS = [ + "#4ECDC4", // 青緑 + "#45B7D1", // 水色 + "#96CEB4", // 緑 + "#FFEAA7", // 黄 + "#DDA0DD", // 紫 + "#87CEEB", // スカイブルー + "#9B59B6", // 濃い紫 + "#3498DB", // 青 +]; + +export const DEFAULT_PARTICIPATION_OPTION = { + label: "参加", + color: "#0F82B1", // PRIMARY_RGB と同じ色 +}; + +export function generateDistinctColor(existingColors: string[]): string { + // 既存の色から最も離れた色を選択 + for (const color of PREDEFINED_COLORS) { + if (!existingColors.includes(color)) { + return color; + } + } + // 全て使用済みの場合はランダム生成(赤系を避ける) + // Hue: 60°~300°(赤を避けるため、0°~60° と 300°~360° を除外) + const hue = 60 + Math.random() * 240; // 60~300 + const saturation = 50 + Math.random() * 30; // 50~80 + const lightness = 50 + Math.random() * 20; // 50~70 + return hslToHex(hue, saturation, lightness); +} + +function hslToHex(h: number, s: number, l: number): string { + const sNormalized = s / 100; + const lNormalized = l / 100; + const c = (1 - Math.abs(2 * lNormalized - 1)) * sNormalized; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = lNormalized - c / 2; + let r = 0; + let g = 0; + let b = 0; + + if (h >= 0 && h < 60) { + r = c; + g = x; + b = 0; + } else if (h >= 60 && h < 120) { + r = x; + g = c; + b = 0; + } else if (h >= 120 && h < 180) { + r = 0; + g = c; + b = x; + } else if (h >= 180 && h < 240) { + r = 0; + g = x; + b = c; + } else if (h >= 240 && h < 300) { + r = x; + g = 0; + b = c; + } else if (h >= 300 && h < 360) { + r = c; + g = 0; + b = x; + } + + const rHex = Math.round((r + m) * 255) + .toString(16) + .padStart(2, "0"); + const gHex = Math.round((g + m) * 255) + .toString(16) + .padStart(2, "0"); + const bHex = Math.round((b + m) * 255) + .toString(16) + .padStart(2, "0"); + + return `#${rHex}${gHex}${bHex}`; +} diff --git a/common/validators.ts b/common/validators.ts index 2a7d68e..91ba844 100644 --- a/common/validators.ts +++ b/common/validators.ts @@ -5,6 +5,19 @@ const isoStrToDate = z .datetime() .transform((str) => new Date(str)); +export const participationOptionSchema = z.object({ + id: z.string().uuid(), + label: z.string().min(1).max(50), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/), +}); + +// 作成時(id はフロントエンドで UUID を生成) +export const participationOptionCreateSchema = z.object({ + id: z.string().uuid(), // フロントエンドで生成 + label: z.string().min(1, "ラベルを入力してください").max(50, "ラベルは50文字以内で入力してください"), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "無効なカラーコードです"), +}); + export const submitReqSchema = z.object({ name: z.string(), projectId: z.string().length(21), @@ -12,6 +25,7 @@ export const submitReqSchema = z.object({ z.object({ start: isoStrToDate, end: isoStrToDate, + participationOptionId: z.string().uuid(), }), ), }); @@ -51,6 +65,7 @@ const baseProjectReqSchema = z.object({ .refine((ranges) => ranges.every(({ startTime, endTime }) => isQuarterHour(startTime) && isQuarterHour(endTime)), { message: "開始時刻と終了時刻は15分単位で入力してください", }), + participationOptions: z.array(participationOptionCreateSchema).optional(), }); export const projectReqSchema = baseProjectReqSchema.refine( diff --git a/server/prisma/migrations/20251116070714_add_participation_option/migration.sql b/server/prisma/migrations/20251116070714_add_participation_option/migration.sql new file mode 100644 index 0000000..8ad2d57 --- /dev/null +++ b/server/prisma/migrations/20251116070714_add_participation_option/migration.sql @@ -0,0 +1,54 @@ +-- CreateTable +CREATE TABLE "ParticipationOption" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "color" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + + CONSTRAINT "ParticipationOption_pkey" PRIMARY KEY ("id") +); + +-- AddColumn (nullable first for data migration) +ALTER TABLE "Slot" ADD COLUMN "participationOptionId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "ParticipationOption_projectId_label_key" ON "ParticipationOption"("projectId", "label"); + +-- AddForeignKey +ALTER TABLE "ParticipationOption" ADD CONSTRAINT "ParticipationOption_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create default ParticipationOption for each Project that has Slots +INSERT INTO "ParticipationOption" ("id", "label", "color", "projectId") +SELECT + gen_random_uuid(), + '参加', + '#0F82B1', + sub."projectId" +FROM (SELECT DISTINCT "projectId" FROM "Slot") AS sub; + +-- Also create for Projects without Slots (recommended for consistency) +INSERT INTO "ParticipationOption" ("id", "label", "color", "projectId") +SELECT + gen_random_uuid(), + '参加', + '#0F82B1', + p."id" +FROM "Project" p +WHERE NOT EXISTS ( + SELECT 1 FROM "ParticipationOption" po WHERE po."projectId" = p."id" +); + +-- Update existing Slots to reference default ParticipationOption +UPDATE "Slot" s +SET "participationOptionId" = ( + SELECT po."id" + FROM "ParticipationOption" po + WHERE po."projectId" = s."projectId" + LIMIT 1 +); + +-- Now make the column NOT NULL +ALTER TABLE "Slot" ALTER COLUMN "participationOptionId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Slot" ADD CONSTRAINT "Slot_participationOptionId_fkey" FOREIGN KEY ("participationOptionId") REFERENCES "ParticipationOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 2913bd3..38b74e0 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -15,18 +15,19 @@ datasource db { /// 日程調整プロジェクト。ユーザには「イベント」と表示。 model Project { - id String @id @db.VarChar(21) - name String - description String? /// イベントの説明(オプショナル) + id String @id @db.VarChar(21) + name String + description String? /// 注: 日付部分のみ利用。時間は考慮しない。 - startDate DateTime + startDate DateTime /// 注: 日付部分のみ利用。時間は考慮しない。 - endDate DateTime + endDate DateTime /// 注: 現在は 1 つのみ設定可能 - allowedRanges AllowedRange[] - slots Slot[] - hosts Host[] - guests Guest[] + allowedRanges AllowedRange[] + slots Slot[] + hosts Host[] + guests Guest[] + participationOptions ParticipationOption[] } /// 日程調整プロジェクトにおいて入力が許可された時間帯。(注: 現在は 1 つの日程調整プロジェクトにつき 1 つのみ設定可能) @@ -42,13 +43,15 @@ model AllowedRange { /// あるゲストが参加可能な時間帯の 1 つ。日を跨がない。 model Slot { - id String @id @default(uuid()) - from DateTime - to DateTime - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - guestId String - guest Guest @relation(fields: [guestId], references: [id]) + id String @id @default(uuid()) + from DateTime + to DateTime + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + guestId String + guest Guest @relation(fields: [guestId], references: [id]) + participationOptionId String + participationOption ParticipationOption @relation(fields: [participationOptionId], references: [id]) } /// 日程調整プロジェクトの作成者。 @@ -72,3 +75,15 @@ model Guest { @@unique([browserId, projectId]) } + +/// 参加形態。 +model ParticipationOption { + id String @id @default(uuid()) + label String + color String + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + slots Slot[] + + @@unique([projectId, label]) +} diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index d46b761..eefea22 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -1,9 +1,11 @@ +import { randomUUID } from "node:crypto"; import { zValidator } from "@hono/zod-validator"; import dotenv from "dotenv"; import { Hono } from "hono"; import { getSignedCookie, setSignedCookie } from "hono/cookie"; import { nanoid } from "nanoid"; import { z } from "zod"; +import { DEFAULT_PARTICIPATION_OPTION } from "../../../common/colors.js"; import { editReqSchema, projectReqSchema, submitReqSchema } from "../../../common/validators.js"; import { cookieOptions, prisma } from "../main.js"; @@ -22,6 +24,23 @@ const router = new Hono() const browserId = (await getSignedCookie(c, cookieSecret, "browserId")) || undefined; try { const data = c.req.valid("json"); + + // 参加形態の処理(指定がない場合はデフォルトを作成) + const participationOptionsData = + data.participationOptions && data.participationOptions.length > 0 + ? data.participationOptions.map((opt) => ({ + id: opt.id, // フロントエンドで生成された UUID をそのまま使用 + label: opt.label, + color: opt.color, + })) + : [ + { + id: randomUUID(), // デフォルト作成時のみサーバーで生成 + label: DEFAULT_PARTICIPATION_OPTION.label, + color: DEFAULT_PARTICIPATION_OPTION.color, + }, + ]; + const event = await prisma.project.create({ data: { id: nanoid(), @@ -40,8 +59,11 @@ const router = new Hono() browserId, }, }, + participationOptions: { + create: participationOptionsData, + }, }, - include: { hosts: true }, + include: { hosts: true, participationOptions: true }, }); const host = event.hosts[0]; @@ -124,6 +146,7 @@ const router = new Hono() where: { id: projectId }, include: { allowedRanges: true, + participationOptions: true, guests: { include: { slots: true, // slots 全部欲しいなら select より include @@ -187,6 +210,55 @@ const router = new Hono() if (!host) { return c.json({ message: "アクセス権限がありません。" }, 403); } + + // 参加形態の更新 + if (data.participationOptions) { + // 最低1つの参加形態が必要 + if (data.participationOptions.length === 0) { + return c.json({ message: "参加形態は最低1つ必要です。" }, 400); + } + + // 削除対象の参加形態に Slot が紐づいているかチェック + const existingOptions = await prisma.participationOption.findMany({ + where: { projectId }, + include: { slots: { select: { id: true } } }, + }); + + const newOptionIds = data.participationOptions.map((o) => o.id); + const optionsToDelete = existingOptions.filter((o) => !newOptionIds.includes(o.id)); + const undeletableOptions = optionsToDelete.filter((o) => o.slots.length > 0); + + if (undeletableOptions.length > 0) { + const labels = undeletableOptions.map((o) => o.label).join(", "); + return c.json( + { + message: `以下の参加形態は日程が登録されているため削除できません: ${labels}`, + }, + 400, + ); + } + + await prisma.$transaction([ + // 既存の参加形態で、新しいリストにないものを削除 + prisma.participationOption.deleteMany({ + where: { + projectId, + id: { + notIn: newOptionIds, + }, + }, + }), + // 既存の参加形態を更新または新規作成 + ...data.participationOptions.map((opt) => + prisma.participationOption.upsert({ + where: { id: opt.id }, + update: { label: opt.label, color: opt.color }, + create: { id: opt.id, label: opt.label, color: opt.color, projectId }, + }), + ), + ]); + } + // 更新処理 const updatedEvent = await prisma.project.update({ where: { id: projectId }, @@ -208,7 +280,7 @@ const router = new Hono() })), }, }, - include: { allowedRanges: true }, + include: { allowedRanges: true, participationOptions: true }, }); return c.json({ event: updatedEvent }, 200); @@ -285,6 +357,7 @@ const router = new Hono() from: slot.start, to: slot.end, projectId, + participationOptionId: slot.participationOptionId, })), }, }, @@ -331,6 +404,7 @@ const router = new Hono() from: slot.start, to: slot.end, projectId, + participationOptionId: slot.participationOptionId, })); await prisma.slot.deleteMany({ where: { guestId: existingGuest.id } });