From 1af80fd462b9ecd492c5917a62d84740eee50a7c Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:17:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9D=A1=E4=BB=B6=E3=81=AB=E5=90=88?= =?UTF-8?q?=E3=81=86=E3=82=B9=E3=83=AD=E3=83=83=E3=83=88=E3=82=92=E5=BC=B7?= =?UTF-8?q?=E8=AA=BF=E3=81=99=E3=82=8B=E3=83=8F=E3=82=A4=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=88=E6=A9=9F=E8=83=BD=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 閲覧モードで「最多人数」または特定の参加者を選ぶと、条件に合う 時間帯だけが浮き上がるようにした。 - ViewingMatrix に getMaxGuestCount / buildHighlight を追加し、 セル単位で判定してから run 化することで連続区間を正しくまとめる - 表示は色を足すのではなく、非該当領域を白ベールで落とすことで 既存の「参加形態の色 × 人数の濃さ」の読み取りを壊さないようにした - ベールは pointer-events-none とし、沈んだ側の内訳ツールチップは維持する - 参加者一覧の行をボタン化し、押すとその人の日程をハイライトする - 編集・確認モードではドラッグ入力の妨げになるため自動で解除する サーバー・DB の変更は不要で、クライアント計算のみで完結する。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TabU9J9gxo7Av5UcrytYko --- client/src/components/Calendar.tsx | 67 ++++++++++++++++++++++++- client/src/lib/CalendarMatrix.ts | 63 ++++++++++++++++++++++- client/src/pages/eventId/Submission.tsx | 66 +++++++++++++++++++----- 3 files changed, 180 insertions(+), 16 deletions(-) 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() {
    {project.guests.map((guest) => { const commentText = guestIdToComment[guest.id]; + const isHighlighted = highlight?.type === "guest" && highlight.guestId === guest.id; return ( -
  • -
    - -
    -
    -

    {guest.name}

    - {commentText && ( -
    - {commentText} -
    - )} -
    +
  • +
  • ); })}