diff --git a/client/src/components/Calendar.tsx b/client/src/components/Calendar.tsx index 6bae9b2..b4c34b6 100644 --- a/client/src/components/Calendar.tsx +++ b/client/src/components/Calendar.tsx @@ -23,6 +23,11 @@ type ParticipationOption = { color: string; }; +/** + * ハイライト条件。同時に有効なのは 1 つだけ。 + */ +export type Highlight = { type: "maxCount" } | { type: "guest"; guestId: string }; + type Props = { startDate: Dayjs; endDate: Dayjs; @@ -33,6 +38,7 @@ type Props = { guestIdToComment: Record; participationOptions: ParticipationOption[]; currentParticipationOptionId: string; + highlight: Highlight | null; editMode: boolean; onChangeEditingSlots: (slots: EditingSlot[]) => void; }; @@ -72,6 +78,7 @@ export const Calendar = ({ guestIdToComment, participationOptions, currentParticipationOptionId, + highlight, editMode, onChangeEditingSlots, }: Props) => { @@ -112,14 +119,28 @@ export const Calendar = ({ }, [editingSlots]); // viewingSlots → ViewingMatrix → rendered slots - const computedViewingSlots = useMemo(() => { + const viewingMatrix = useMemo(() => { const matrix = new ViewingMatrix(countDays, startDate); for (const slot of viewingSlots) { matrix.setGuestRange(slot.from, slot.to, slot.guestId, slot.optionId); } - return matrix.getSlots(); + return matrix; }, [viewingSlots, countDays, startDate]); + const computedViewingSlots = useMemo(() => viewingMatrix.getSlots(), [viewingMatrix]); + + // ハイライト条件を満たすセルを求め、連続区間にまとめる + const highlightSlots = useMemo(() => { + if (!highlight) return []; + if (highlight.type === "guest") { + const { guestId } = highlight; + return viewingMatrix.buildHighlight((cell) => guestId in cell).getSlots(); + } + const maxCount = viewingMatrix.getMaxGuestCount(); + if (maxCount === 0) return []; + return viewingMatrix.buildHighlight((cell) => Object.keys(cell).length === maxCount).getSlots(); + }, [viewingMatrix, highlight]); + // セル座標変換ヘルパー(毎レンダーで最新クロージャを利用) const xyToCell = (x: number, y: number) => { const el = gridRef.current; @@ -139,6 +160,39 @@ export const Calendar = ({ const toSlotIdx = (dt: Dayjs) => (dt.hour() * 60 + dt.minute() - slotStartMinutes) / 15; + /** + * ハイライトの「補集合」を矩形として列挙する。ここを白ベールで覆うことで、 + * 既存の(参加形態の色 × 人数の濃さ)表現を汚さずに該当区間だけを浮き上がらせる。 + */ + const veilRects = useMemo(() => { + if (highlightSlots.length === 0) return []; + + const perDay: { from: number; to: number }[][] = Array.from({ length: countDays }, () => []); + for (const slot of highlightSlots) { + const dayIdx = slot.from.startOf("day").diff(startDate.startOf("day"), "day"); + if (dayIdx < 0 || dayIdx >= countDays) continue; + const rawFrom = (slot.from.hour() * 60 + slot.from.minute() - slotStartMinutes) / 15; + // to は 24:00(翌日 0:00)になりうるので、from からの経過時間で求める + const rawTo = rawFrom + slot.to.diff(slot.from, "minute") / 15; + const from = Math.max(0, rawFrom); + const to = Math.min(slotCount, rawTo); + if (to <= from) continue; + perDay[dayIdx].push({ from, to }); + } + + const rects: { day: number; from: number; to: number }[] = []; + for (let day = 0; day < countDays; day++) { + const ranges = perDay[day].sort((a, b) => a.from - b.from); + let cursor = 0; + for (const range of ranges) { + if (range.from > cursor) rects.push({ day, from: cursor, to: range.from }); + cursor = Math.max(cursor, range.to); + } + if (cursor < slotCount) rects.push({ day, from: cursor, to: slotCount }); + } + return rects; + }, [highlightSlots, countDays, startDate, slotCount, slotStartMinutes]); + updatePreviewRef.current = (x: number, y: number) => { const cell = xyToCell(x, y); const s = dragStart.current; @@ -431,6 +485,15 @@ export const Calendar = ({ ); })} + {/* ハイライト: 非該当領域を白ベールで落とす(pointer-events-none で内訳ツールチップは維持) */} + {veilRects.map((rect) => ( +
+ ))} + {/* 編集中スロット(自分の登録済み時間) */} {slots.map((slot) => { const dayIdx = slot.from.startOf("day").diff(startDate.startOf("day"), "day"); diff --git a/client/src/lib/CalendarMatrix.ts b/client/src/lib/CalendarMatrix.ts index 593d892..81466d5 100644 --- a/client/src/lib/CalendarMatrix.ts +++ b/client/src/lib/CalendarMatrix.ts @@ -6,6 +6,11 @@ export type EditingMatrixSlot = { optionId: string; }; +export type HighlightMatrixSlot = { + from: Dayjs; + to: Dayjs; +}; + export type ViewingMatrixSlot = { from: Dayjs; to: Dayjs; @@ -60,7 +65,7 @@ abstract class CalendarMatrixBase { Array.from({ length: this.quarterCount }, () => null), ); } - abstract getSlots(): EditingMatrixSlot[] | ViewingMatrixSlot[]; + abstract getSlots(): EditingMatrixSlot[] | ViewingMatrixSlot[] | HighlightMatrixSlot[]; } /** @@ -104,6 +109,38 @@ export class ViewingMatrix extends CalendarMatrixBase> { } } + /** + * いずれかのゲストが登録しているセルのうち、最も参加人数が多いセルの人数を返す。 + * 誰も登録していない場合は 0。 + */ + getMaxGuestCount(): number { + let max = 0; + for (const row of this.matrix) { + for (const cell of row) { + if (cell === null) continue; + const count = Object.keys(cell).length; + if (count > max) max = count; + } + } + return max; + } + + /** + * 各セルに述語を適用し、条件を満たすセルだけを立てた {@link HighlightMatrix} を返す。 + * セル単位で判定してから run 化するため、連続区間が正しくまとまる。 + */ + buildHighlight(predicate: (cell: Record) => boolean): HighlightMatrix { + const highlight = new HighlightMatrix(this.matrix.length, this.initialDatetime); + for (let day = 0; day < this.matrix.length; day++) { + for (let quarter = 0; quarter < this.quarterCount; quarter++) { + const cell = this.matrix[day][quarter]; + if (cell === null || !predicate(cell)) continue; + highlight.mark(day, quarter); + } + } + return highlight; + } + getSlots(): ViewingMatrixSlot[] { const slots: ViewingMatrixSlot[] = []; for (let day = 0; day < this.matrix.length; day++) { @@ -126,6 +163,30 @@ export class ViewingMatrix extends CalendarMatrixBase> { } } +/** + * ハイライト対象セルの {@link CalendarMatrixBase}。セル値は「対象である」ことのみを表す。 + */ +export class HighlightMatrix extends CalendarMatrixBase { + mark(day: number, quarter: number): void { + if (!this.isInBounds(day, quarter)) return; + this.matrix[day][quarter] = true; + } + + getSlots(): HighlightMatrixSlot[] { + const slots: HighlightMatrixSlot[] = []; + for (let day = 0; day < this.matrix.length; day++) { + const runs = findRuns(this.matrix[day], () => true); + for (const run of runs) { + slots.push({ + from: this.initialDatetime.add(day, "day").add(run.start * 15, "minute"), + to: this.initialDatetime.add(day, "day").add(run.end * 15, "minute"), + }); + } + } + return slots; + } +} + function isSameRecordShallow(a: Record, b: Record): boolean { const aKeys = Object.keys(a); const bKeys = Object.keys(b); diff --git a/client/src/pages/eventId/Submission.tsx b/client/src/pages/eventId/Submission.tsx index 7de8c2f..6893e5b 100644 --- a/client/src/pages/eventId/Submission.tsx +++ b/client/src/pages/eventId/Submission.tsx @@ -11,12 +11,13 @@ import { LuSend, LuSettings2, LuUser, + LuUsers, LuX, } from "react-icons/lu"; import { NavLink, useParams } from "react-router"; import type { AppType } from "../../../../server/src/main"; import { AddToCalendar } from "../../components/AddToCalendar"; -import { Calendar } from "../../components/Calendar"; +import { Calendar, type Highlight } from "../../components/Calendar"; import Header from "../../components/Header"; import { projectReviver } from "../../revivers"; import type { Project, Slot } from "../../types"; @@ -146,6 +147,8 @@ export default function SubmissionPage() { const [comment, setComment] = useState(meAsGuest?.comment ?? ""); + const [highlight, setHighlight] = useState(null); + const [descriptionExpanded, setDescriptionExpanded] = useState(false); const [guestListExpanded, setGuestListExpanded] = useState(false); @@ -229,6 +232,11 @@ export default function SubmissionPage() { } }, [meAsGuest]); + // 編集・確認モードではベールがドラッグ入力の邪魔になるため解除する + useEffect(() => { + if (mode !== "view") setHighlight(null); + }, [mode]); + const guestIdToName = useMemo(() => { if (!project) return {}; return Object.fromEntries(project.guests.map((g) => [g.id, g.name])); @@ -394,6 +402,27 @@ export default function SubmissionPage() {
)} + {/* ハイライト操作バー */} + {mode === "view" && project.guests.length > 0 && ( +
+ + {highlight?.type === "guest" && ( + + )} +
+ )} + @@ -423,19 +453,29 @@ export default function SubmissionPage() {