diff --git a/README.md b/README.md index 172f03f..576d427 100644 --- a/README.md +++ b/README.md @@ -22,16 +22,23 @@ npm ci ``` -Prisma Client の生成 +`server/.env.sample` をコピーして `server/.env` を作成 + +`client/.env.local.sample` をコピーして `client/.env.local` を作成 + +開発用データベースの起動 ```sh -cd server -npx prisma generate +docker compose up ``` -`server/.env.sample` をコピーして `server/.env` を作成 +スキーマの反映 + +```sh +cd server +npx prisma migrate dev +``` -`client/.env.local.sample` をコピーして `client/.env.local` を作成 ### 起動 開発用データベースの起動 diff --git a/client/src/components/Calendar.tsx b/client/src/components/Calendar.tsx index f473e81..9d8eab4 100644 --- a/client/src/components/Calendar.tsx +++ b/client/src/components/Calendar.tsx @@ -1,7 +1,7 @@ import interactionPlugin from "@fullcalendar/interaction"; import FullCalendar from "@fullcalendar/react"; import timeGridPlugin from "@fullcalendar/timegrid"; -import dayjs, { type Dayjs } from "dayjs"; +import dayjs from "dayjs"; import "dayjs/locale/ja"; import type { DateSelectArg, @@ -12,101 +12,123 @@ import type { SlotLabelContentArg, } from "@fullcalendar/core/index.js"; import type React from "react"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Tooltip } from "react-tooltip"; -import type { Project } from "../types"; +import { EditingMatrix, ViewingMatrix } from "../lib/CalendarMatrix"; +import type { EditingSlot } from "../pages/eventId/Submission"; dayjs.locale("ja"); +type AllowedRange = { + startTime: Date; + endTime: Date; +}; + +type ViewingSlot = { + from: Date; + to: Date; + guestName: string; +}; + type Props = { - project: Project; - myGuestId: string; - mySlotsRef: React.RefObject<{ from: Date; to: Date }[]>; + startDate: Date; + endDate: Date; + allowedRanges: AllowedRange[]; + editingSlots: EditingSlot[]; + viewingSlots: ViewingSlot[]; editMode: boolean; + onChangeEditingSlots: (slots: EditingSlot[]) => void; }; const OPACITY = 0.2; const PRIMARY_RGB = [15, 130, 177]; -const MY_EVENT = "ih-my-event"; -const OTHERS_EVENT = "ih-others-event"; +const EDITING_EVENT = "ih-editing-event"; +const VIEWING_EVENT = "ih-viewing-event"; const SELECT_EVENT = "ih-select-event"; const CREATE_SELECT_EVENT = "ih-create-select-event"; const DELETE_SELECT_EVENT = "ih-delete-select-event"; -export const Calendar = ({ project, myGuestId, mySlotsRef, editMode }: Props) => { - const countDays = dayjs(project.endDate).startOf("day").diff(dayjs(project.startDate).startOf("day"), "day") + 1; +export const Calendar = ({ + startDate, + endDate, + allowedRanges, + editingSlots, + viewingSlots, + editMode, + onChangeEditingSlots, +}: Props) => { + const countDays = dayjs(endDate).startOf("day").diff(dayjs(startDate).startOf("day"), "day") + 1; // TODO: +1 は不要かも - const myMatrixRef = useRef(new CalendarMatrix(countDays + 1, project.startDate)); - const othersMatrixRef = useRef(new CalendarMatrix(countDays + 1, project.startDate, true)); + const editingMatrixRef = useRef(new EditingMatrix(countDays + 1, startDate)); + const viewingMatrixRef = useRef(new ViewingMatrix(countDays + 1, startDate)); // TODO: 現在は最初の選択範囲のみ。FullCalendar の制約により、複数の allowedRanges には対応できないため、のちに selectAllow などで独自実装が必要 - const tmpAllowedRange = project.allowedRanges[0] ?? { - startTime: dayjs(new Date()).set("hour", 0).set("minute", 0), - endTime: dayjs(new Date()).set("hour", 23).set("minute", 59), + const tmpAllowedRange = allowedRanges[0] ?? { + startTime: dayjs(new Date()).set("hour", 0).set("minute", 0).toDate(), + endTime: dayjs(new Date()).set("hour", 23).set("minute", 59).toDate(), }; const calendarRef = useRef(null); const isSelectionDeleting = useRef(null); - // init + // 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 }; + }> + >([]); + + // editingSlots/viewingSlots → matrix → events useEffect(() => { - const calendarApi = calendarRef.current?.getApi(); - - if (calendarApi) { - calendarApi.getEvents().forEach((event) => { - event.remove(); - }); - mySlotsRef.current = []; - myMatrixRef.current.clear(); - othersMatrixRef.current.clear(); - - const slots = project.guests.flatMap((guest) => - guest.slots.map((slot) => ({ - ...slot, - guestName: guest.name, - })), - ); - slots.forEach((slot) => { - const { from, to } = getVertexes(new Date(slot.from), new Date(slot.to)); - if (editMode && slot.guestId === myGuestId) { - myMatrixRef.current.setRange(from, to, 1); - } else { - othersMatrixRef.current.incrementRange(from, to, slot.guestName); - } - }); - myMatrixRef.current.getSlots().forEach((slot) => { - calendarApi.addEvent({ - id: MY_EVENT, - className: MY_EVENT, - start: slot.from, - end: slot.to, - textColor: "black", - }); - mySlotsRef.current.push({ - from: slot.from, - to: slot.to, - }); - }); - othersMatrixRef.current.getSlots().forEach((slot) => { - calendarApi.addEvent({ - id: OTHERS_EVENT, - className: OTHERS_EVENT, - start: slot.from, - end: slot.to, - color: `rgba(${PRIMARY_RGB.join(",")}, ${(1 - (1 - OPACITY) ** slot.weight).toFixed(3)})`, - display: "background", - extendedProps: { - members: slot.guestNames, - countMembers: slot.weight, - }, - }); - }); - } - }, [myGuestId, mySlotsRef, project, editMode]); + // editingSlots → editingMatrix + editingMatrixRef.current.clear(); + editingSlots.forEach((slot) => { + const { from, to } = getVertexes(slot.from, slot.to); + editingMatrixRef.current.setRange(from, to, 1); + }); + + // viewingSlots → viewingMatrix + viewingMatrixRef.current.clear(); + viewingSlots.forEach((slot) => { + const { from, to } = getVertexes(slot.from, slot.to); + viewingMatrixRef.current.incrementRange(from, to, slot.guestName); + }); + // 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, + }, + })); + + setEvents([...editingEvents, ...viewingEvents]); + }, [editingSlots, viewingSlots]); + + // カレンダー外までドラッグした際に選択を解除するためのイベントハンドラを登録 useEffect(() => { - // カレンダー外までドラッグした際に選択を解除 const handleMouseUp = (e: MouseEvent | TouchEvent) => { const calendarEl = document.getElementById("ih-cal-wrapper"); @@ -151,11 +173,6 @@ export const Calendar = ({ project, myGuestId, mySlotsRef, editMode }: Props) => timeGrid: { type: "timeGrid", duration: { days: Math.min(countDays, 7) }, - // TODO: not working..? - // visibleRange: { - // start: project.startDate, - // end: project.endDate, - // }, dayHeaderContent: (args: DayHeaderContentArg) => { return (
@@ -169,20 +186,20 @@ export const Calendar = ({ project, myGuestId, mySlotsRef, editMode }: Props) => }, slotLabelInterval: "00:30:00", validRange: { - start: project.startDate, - end: project.endDate, + start: startDate, + end: endDate, }, expandRows: true, }, }), - [countDays, project], + [countDays, startDate, endDate], ); const handleSelectAllow = useCallback( // 選択中に選択範囲を表示する (info: DateSpanApi) => { if (!editMode) return false; - return displaySelection(info, isSelectionDeleting, calendarRef, myMatrixRef); + return displaySelection(info, isSelectionDeleting, calendarRef, editingMatrixRef); }, [editMode], ); @@ -190,21 +207,43 @@ export const Calendar = ({ project, myGuestId, mySlotsRef, editMode }: Props) => const handleSelect = useCallback( // 選択が完了した際に編集する (info: DateSelectArg) => { - if (!editMode) return false; - edit(info, isSelectionDeleting, calendarRef, myMatrixRef, mySlotsRef); + if (!editMode) return; + + const { from, to } = getVertexes(info.start, info.end); + + if (isSelectionDeleting.current === null) return; + const isDeletion = isSelectionDeleting.current; + + // matrix を更新 + editingMatrixRef.current.setRange(from, to, isDeletion ? 0 : 1); + + // matrix → editingSlots + const newSlots = editingMatrixRef.current.getSlots().map((slot) => ({ + from: slot.from, + to: slot.to, + })); + onChangeEditingSlots(newSlots); + + // 選択範囲をクリア + const calendarApi = calendarRef.current?.getApi(); + const existingSelection = calendarApi?.getEventById(SELECT_EVENT); + if (existingSelection) { + existingSelection.remove(); + } + isSelectionDeleting.current = null; }, - [editMode, mySlotsRef], + [editMode, onChangeEditingSlots], ); const handleEventDidMount = useCallback((info: EventMountArg) => { - if (info.event.id === MY_EVENT) { + if (info.event.classNames.includes(EDITING_EVENT)) { // 既存の event 上で選択できるようにするため。 info.el.style.pointerEvents = "none"; } }, []); const handleEventContent = useCallback((info: EventContentArg) => { - if (info.event.id === OTHERS_EVENT) { + if (info.event.classNames.includes(VIEWING_EVENT)) { return (
); } - if (info.event.id === MY_EVENT) { + if (info.event.classNames.includes(EDITING_EVENT)) { return (
{`${dayjs(info.event.start).format("HH:mm")} - ${dayjs(info.event.end).format("HH:mm")}`}
); @@ -234,12 +273,13 @@ export const Calendar = ({ project, myGuestId, mySlotsRef, editMode }: Props) => longPressDelay={200} slotDuration={"00:15:00"} allDaySlot={false} - initialDate={project.startDate} + initialDate={startDate} slotMinTime={dayjs(tmpAllowedRange.startTime).format("HH:mm:ss")} slotMaxTime={dayjs(tmpAllowedRange.endTime).format("HH:mm:ss")} headerToolbar={headerToolbar} views={views} initialView="timeGrid" + events={events} selectable={true} selectAllow={handleSelectAllow} select={handleSelect} @@ -255,7 +295,7 @@ function displaySelection( info: DateSpanApi, isSelectionDeleting: React.RefObject, calendarRef: React.RefObject, - myMatrixRef: React.RefObject, + myMatrixRef: React.RefObject, ) { // 選択範囲の表示 // 通常の selection では矩形選択ができないため、イベントを作成することで選択範囲を表現している。 @@ -305,153 +345,6 @@ function displaySelection( return true; } -function edit( - info: DateSelectArg, - isSelectionDeleting: React.RefObject, - calendarRef: React.RefObject, - myMatrixRef: React.RefObject, - mySlotsRef: React.RefObject<{ from: Date; to: Date }[]>, -) { - const { from, to } = getVertexes(info.start, info.end); - - if (isSelectionDeleting.current === null) return; - if (!calendarRef.current) return; - const isDeletion = isSelectionDeleting.current; - - if (!calendarRef.current) return; - const calendarApi = calendarRef.current.getApi(); - - calendarApi.getEvents().forEach((event) => { - if (event.id !== MY_EVENT) return; - event.remove(); - }); - mySlotsRef.current = []; - - myMatrixRef.current.setRange(from, to, isDeletion ? 0 : 1); - myMatrixRef.current.getSlots().forEach((slot) => { - calendarApi.addEvent({ - start: slot.from, - end: slot.to, - id: MY_EVENT, - className: MY_EVENT, - textColor: "black", - }); - mySlotsRef.current.push({ - from: slot.from, - to: slot.to, - }); - }); - - // 選択範囲をクリア - const existingSelection = calendarApi.getEventById(SELECT_EVENT); - if (existingSelection) { - existingSelection.remove(); - } - isSelectionDeleting.current = null; -} - -class CalendarMatrix { - private matrix: number[][]; - private guestNames: string[][][] | null; - /** - * 15 分を 1 セルとしたセルの数 (96 = 24 * 4) - */ - private readonly quarterCount = 96; - private initialDate: Dayjs; - - constructor(dayCount: number, initialDate: Date, hasGuestNames?: boolean) { - this.matrix = Array.from({ length: dayCount }, () => Array.from({ length: this.quarterCount }, () => 0)); - this.guestNames = hasGuestNames - ? Array.from({ length: dayCount }, () => Array.from({ length: this.quarterCount }, () => [])) - : null; - this.initialDate = dayjs(initialDate).startOf("day"); - } - - private getIndex(date: Date) { - const totalMinutes = date.getHours() * 60 + date.getMinutes(); - const dayDiff = dayjs(date).startOf("day").diff(this.initialDate, "day"); - return [dayDiff, Math.floor(totalMinutes / 15)]; - } - - getIsSlotExist(date: Date): boolean { - const [row, col] = this.getIndex(date); - return this.matrix[row][col] !== 0; - } - - getSlots() { - const slots: { from: Date; to: Date; weight: number; guestNames?: string[] }[] = []; - for (let day = 0; day < this.matrix.length; day++) { - let eventCount = null; - let start: Date | null = null; - let startGuestNames: string[] | null = null; - for (let q = 0; q < this.matrix[day].length; q++) { - const currentCell = this.matrix[day][q]; - if (eventCount !== currentCell) { - if (start) { - const from = start; - const to = this.initialDate - .add(day, "day") - .add(q * 15, "minute") - .toDate(); - const weight = eventCount ?? 0; - slots.push({ from, to, weight, guestNames: startGuestNames ?? undefined }); - start = null; - } - if (currentCell !== 0) { - start = this.initialDate - .add(day, "day") - .add(q * 15, "minute") - .toDate(); - startGuestNames = this.guestNames?.[day][q] ?? null; - } - eventCount = currentCell; - } - } - } - return slots; - } - - setSlot(from: Date, to: Date, newValue: number): 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] = newValue; - } - } - } - - setRange(from: Date, to: Date, newValue: number): 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] = newValue; - } - } - } - - incrementRange(from: Date, to: Date, guestName: 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; - if (this.guestNames) { - this.guestNames[r][c].push(guestName); - } - } - } - } - - clear() { - 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 }, () => []), - ); - } -} - /** * 矩形選択した際の左上と右下の頂点を返す。from < to が前提 */ diff --git a/client/src/index.css b/client/src/index.css index bacc227..3f01223 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -52,14 +52,14 @@ border: 3px dashed red !important; } -/* 自分の日程 */ -.ih-my-event { +/* 編集中のイベント */ +.ih-editing-event { border: 3px solid var(--color-primary) !important; background-color: var(--ih-color-primary-transparent) !important; } -/* 他者の日程 */ -.ih-others-event { +/* 閲覧中のイベント */ +.ih-viewing-event { border: 3px solid var(--color-gray-200) !important; /* background-color は重ね合わせ個数分 JS で計算*/ } diff --git a/client/src/lib/CalendarMatrix.ts b/client/src/lib/CalendarMatrix.ts new file mode 100644 index 0000000..974e997 --- /dev/null +++ b/client/src/lib/CalendarMatrix.ts @@ -0,0 +1,195 @@ +import dayjs, { type Dayjs } from "dayjs"; +import "dayjs/locale/ja"; + +dayjs.locale("ja"); + +export type EditingMatrixSlot = { + from: Date; + to: Date; + weight: number; +}; + +export type ViewingMatrixSlot = { + from: Date; + to: Date; + weight: number; + guestNames: string[]; +}; + +/** + * イベントのマージ計算用に、カレンダーを2次元行列で管理 + */ +abstract class CalendarMatrixBase { + protected matrix: number[][]; + /** + * 15 分を 1 セルとしたセルの数 (96 = 24 * 4) + */ + protected readonly quarterCount = 96; + protected initialDate: Dayjs; + + constructor(dayCount: number, initialDate: Date) { + this.matrix = Array.from({ length: dayCount }, () => Array.from({ length: this.quarterCount }, () => 0)); + this.initialDate = dayjs(initialDate).startOf("day"); + } + + protected getIndex(date: Date): [number, number] { + const totalMinutes = date.getHours() * 60 + date.getMinutes(); + const dayDiff = dayjs(date).startOf("day").diff(this.initialDate, "day"); + return [dayDiff, Math.floor(totalMinutes / 15)]; + } + + getIsSlotExist(date: Date): boolean { + const [row, col] = this.getIndex(date); + return this.matrix[row][col] !== 0; + } + + abstract getSlots(): EditingMatrixSlot[] | ViewingMatrixSlot[]; + abstract clear(): void; +} + +/** + * 編集中イベントの {@link CalendarMatrixBase} + */ +export class EditingMatrix extends CalendarMatrixBase { + setRange(from: Date, to: Date, newValue: number): 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] = newValue; + } + } + } + + getSlots(): EditingMatrixSlot[] { + const slots: EditingMatrixSlot[] = []; + for (let day = 0; day < this.matrix.length; day++) { + const runs = findRuns(this.matrix[day], (a, b) => a === b); + slots.push(...this.convertRunsToSlots(runs, day)); + } + 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)); + } +} + +/** + * 閲覧中イベントの {@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 { + 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); + } + } + } + + getSlots(): ViewingMatrixSlot[] { + const slots: ViewingMatrixSlot[] = []; + for (let day = 0; day < this.matrix.length; day++) { + const runs = findRuns(this.matrix[day], (a, b) => 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 }; + }) + ); + } + + 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 }, () => []), + ); + } +} + +/** + * 配列から、同じ値が連続する区間 (run) を抽出して返す。null の区間は含まない。 + */ +function findRuns(array: ReadonlyArray, isSame: (a: T, b: T) => boolean) { + const runs: { + start: number; // inclusive + end: number; // exclusive + value: T; + }[] = []; + + let currentRun: { + value: T; + start: number; + } | null = null; + + for (let i = 0; i <= array.length; i++) { + // 番兵として、ループを 1 回余分に回し、その際の値は null とする + const value = i < array.length ? array[i] : null; + + // 値が連続している場合は何もしない + const isContinuation = currentRun && value !== null && isSame(currentRun.value, value); + if (isContinuation) continue; + + // 値が変わっている場合は + // 作成中の run があれば、その run を閉じる + if (currentRun) { + runs.push({ + start: currentRun.start, + end: i, + value: currentRun.value, + }); + } + // 新しい run の開始 + currentRun = value !== null ? { value, start: i } : null; + } + + return runs; +} diff --git a/client/src/pages/eventId/Submission.tsx b/client/src/pages/eventId/Submission.tsx index 636a310..1f9a9c2 100644 --- a/client/src/pages/eventId/Submission.tsx +++ b/client/src/pages/eventId/Submission.tsx @@ -1,5 +1,5 @@ import { hc } from "hono/client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { HiOutlineCheckCircle, HiOutlineCog, @@ -12,11 +12,13 @@ import type { AppType } from "../../../../server/src/main"; import { Calendar } from "../../components/Calendar"; import Header from "../../components/Header"; import { projectReviver } from "../../revivers"; -import type { Project } from "../../types"; +import type { Project, Slot } from "../../types"; import { API_ENDPOINT } from "../../utils"; const client = hc(API_ENDPOINT); +export type EditingSlot = Pick; + export default function SubmissionPage() { const { eventId: projectId } = useParams<{ eventId: string }>(); const [project, setProject] = useState(null); @@ -65,7 +67,7 @@ export default function SubmissionPage() { const [guestName, setGuestName] = useState(meAsGuest?.name ?? ""); - const mySlotsRef = useRef<{ from: Date; to: Date }[]>([]); + const [editingSlots, setEditingSlots] = useState([]); const [toast, setToast] = useState<{ message: string; @@ -143,6 +145,45 @@ export default function SubmissionPage() { } }, [meAsGuest]); + // init editing slots + useEffect(() => { + if (project?.meAsGuest?.slots && editMode) { + setEditingSlots( + project.meAsGuest.slots.map((slot) => ({ + from: slot.from, + to: slot.to, + })), + ); + } + }, [project, editMode]); + + // init viewing slots + const viewingSlots = useMemo(() => { + if (!project) return []; + + if (editMode) { + // 編集モードの場合、自分のスロットは editingSlots に入るので、こちらには自分以外のスロットのみ含める + return project.guests + .filter((g) => g.id !== myGuestId) + .flatMap((g) => + g.slots.map((s) => ({ + from: s.from, + to: s.to, + guestName: g.name, + })), + ); + } + + // 閲覧モードの場合は自分も含めて全て + return project.guests.flatMap((g) => + g.slots.map((s) => ({ + from: s.from, + to: s.to, + guestName: g.name, + })), + ); + }, [project, myGuestId, editMode]); + return ( <>
@@ -172,7 +213,15 @@ export default function SubmissionPage() { {project.description && (

{project.description}

)} - +
{editMode ? ( <> @@ -191,7 +240,7 @@ export default function SubmissionPage() { disabled={loading} onClick={async () => { if (confirm("更新をキャンセルします。よろしいですか?")) { - mySlotsRef.current = []; + setEditingSlots([]); setEditMode(false); } }} @@ -206,7 +255,7 @@ export default function SubmissionPage() { onClick={() => { if (!guestName) return; postSubmissions( - mySlotsRef.current.map((slot) => { + editingSlots.map((slot) => { return { start: slot.from, end: slot.to }; }), myGuestId ?? "", diff --git a/client/src/types.ts b/client/src/types.ts index b76ab0d..a55d5e9 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -1,4 +1,4 @@ -type Slot = { +export type Slot = { id: string; projectId: string; guestId: string; diff --git a/package-lock.json b/package-lock.json index e2a16b8..f224bda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1111,53 +1111,53 @@ "license": "MIT" }, "node_modules/@fullcalendar/core": { - "version": "6.1.17", - "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.17.tgz", - "integrity": "sha512-0W7lnIrv18ruJ5zeWBeNZXO8qCWlzxDdp9COFEsZnyNjiEhUVnrW/dPbjRKYpL0edGG0/Lhs0ghp1z/5ekt8ZA==", + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.19.tgz", + "integrity": "sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==", "license": "MIT", "dependencies": { "preact": "~10.12.1" } }, "node_modules/@fullcalendar/daygrid": { - "version": "6.1.17", - "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.17.tgz", - "integrity": "sha512-K7m+pd7oVJ9fW4h7CLDdDGJbc9szJ1xDU1DZ2ag+7oOo1aCNLv44CehzkkknM6r8EYlOOhgaelxQpKAI4glj7A==", + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.19.tgz", + "integrity": "sha512-IAAfnMICnVWPjpT4zi87i3FEw0xxSza0avqY/HedKEz+l5MTBYvCDPOWDATpzXoLut3aACsjktIyw9thvIcRYQ==", "license": "MIT", "peerDependencies": { - "@fullcalendar/core": "~6.1.17" + "@fullcalendar/core": "~6.1.19" } }, "node_modules/@fullcalendar/interaction": { - "version": "6.1.17", - "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.17.tgz", - "integrity": "sha512-AudvQvgmJP2FU89wpSulUUjeWv24SuyCx8FzH2WIPVaYg+vDGGYarI7K6PcM3TH7B/CyaBjm5Rqw9lXgnwt5YA==", + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.19.tgz", + "integrity": "sha512-GOciy79xe8JMVp+1evAU3ytdwN/7tv35t5i1vFkifiuWcQMLC/JnLg/RA2s4sYmQwoYhTw/p4GLcP0gO5B3X5w==", "license": "MIT", "peerDependencies": { - "@fullcalendar/core": "~6.1.17" + "@fullcalendar/core": "~6.1.19" } }, "node_modules/@fullcalendar/react": { - "version": "6.1.17", - "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.17.tgz", - "integrity": "sha512-AA8soHhlfRH5dUeqHnfAtzDiXa2vrgWocJSK/F5qzw/pOxc9MqpuoS/nQBROWtHHg6yQUg3DoGqOOhi7dmylXQ==", + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.19.tgz", + "integrity": "sha512-FP78vnyylaL/btZeHig8LQgfHgfwxLaIG6sKbNkzkPkKEACv11UyyBoTSkaavPsHtXvAkcTED1l7TOunAyPEnA==", "license": "MIT", "peerDependencies": { - "@fullcalendar/core": "~6.1.17", + "@fullcalendar/core": "~6.1.19", "react": "^16.7.0 || ^17 || ^18 || ^19", "react-dom": "^16.7.0 || ^17 || ^18 || ^19" } }, "node_modules/@fullcalendar/timegrid": { - "version": "6.1.17", - "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.17.tgz", - "integrity": "sha512-K4PlA3L3lclLOs3IX8cvddeiJI9ZVMD7RA9IqaWwbvac771971foc9tFze9YY+Pqesf6S+vhS2dWtEVlERaGlQ==", + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.19.tgz", + "integrity": "sha512-OuzpUueyO9wB5OZ8rs7TWIoqvu4v3yEqdDxZ2VcsMldCpYJRiOe7yHWKr4ap5Tb0fs7Rjbserc/b6Nt7ol6BRg==", "license": "MIT", "dependencies": { - "@fullcalendar/daygrid": "~6.1.17" + "@fullcalendar/daygrid": "~6.1.19" }, "peerDependencies": { - "@fullcalendar/core": "~6.1.17" + "@fullcalendar/core": "~6.1.19" } }, "node_modules/@hono/node-server": { @@ -3640,20 +3640,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",