diff --git a/client/src/components/Calendar.tsx b/client/src/components/Calendar.tsx index 6bae9b2..9251279 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; }; @@ -54,6 +60,16 @@ const MAX_SCROLL_SPEED = 8; const OPACITY = 0.2; const PRIMARY_RGB: [number, number, number] = [15, 130, 177]; +/** + * 編集中に自分の予定と見分けられるよう、他ゲストの色を無彩色に落とす。 + * 明度は帯域にクランプし、淡い参加形態色が薄すぎて見えなくならないようにする。 + */ +function toGrayscale([r, g, b]: [number, number, number]): [number, number, number] { + const luminance = 0.299 * r + 0.587 * g + 0.114 * b; + const y = Math.round(Math.min(Math.max(luminance, 90), 170)); + return [y, y, y]; +} + // 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); @@ -72,6 +88,7 @@ export const Calendar = ({ guestIdToComment, participationOptions, currentParticipationOptionId, + highlight, editMode, onChangeEditingSlots, }: Props) => { @@ -112,14 +129,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 +170,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; @@ -365,18 +429,19 @@ export const Calendar = ({ .map((opt) => { const guestIds = optionGroups.get(opt.id) ?? []; const opacity = 1 - (1 - OPACITY) ** guestIds.length; - return { ...opt, guestIds, opacity }; + const rgb = editMode ? toGrayscale(hexToRgb(opt.color)) : hexToRgb(opt.color); + return { ...opt, guestIds, opacity, rgb, displayColor: `rgb(${rgb.join(",")})` }; }); let background: string; if (breakdown.length === 1) { - const [r, g, b] = hexToRgb(breakdown[0].color); + const [r, g, b] = breakdown[0].rgb; background = `rgba(${r},${g},${b},${breakdown[0].opacity.toFixed(3)})`; } else if (breakdown.length > 1) { const w = 100 / breakdown.length; const stops = breakdown .map((bd, j) => { - const [r, g, b] = hexToRgb(bd.color); + const [r, g, b] = bd.rgb; return `rgba(${r},${g},${b},${bd.opacity.toFixed(3)}) ${j * w}%, rgba(${r},${g},${b},${bd.opacity.toFixed(3)}) ${(j + 1) * w}%`; }) .join(", "); @@ -416,7 +481,7 @@ export const Calendar = ({ > ( +
+ ))} + {/* 編集中スロット(自分の登録済み時間) */} {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} -
    - )} -
    +
  • +
  • ); })} diff --git a/server/.env.sample b/server/.env.sample index 6e54204..67c5cde 100644 --- a/server/.env.sample +++ b/server/.env.sample @@ -1,5 +1,5 @@ DATABASE_URL=postgresql://postgres:password@localhost:5432/itsuhima_dev CORS_ALLOW_ORIGINS=http://localhost:5173 DOMAIN=localhost -NODE_ENV=dev # dev or prod +NODE_ENV=dev # dev or production COOKIE_SECRET=your-random-secret-key-for-development diff --git a/server/src/main.ts b/server/src/main.ts index 4142500..69f937f 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -52,7 +52,7 @@ serve( }, ); -const isProduction = process.env.NODE_ENV === "prod"; +const isProduction = process.env.NODE_ENV === "production"; export const cookieOptions = { path: "/",