diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7bb06af --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## コマンド + +```bash +# 開発サーバー起動(クライアント + サーバー個別) +npm run dev:client # Vite dev server → http://localhost:5173 +npm run dev:server # tsx watch mode → http://localhost:3000 +npm run dev:functions # Cloudflare Pages Functions ローカル実行 + +# Lint / Format +npm run check # Biome でチェックのみ +npm run fix:safe # 安全な自動修正(pre-commit フックで自動実行) +npm run fix # unsafe 修正も含む + +# ビルド +cd client && npm run build # 本番ビルド +cd server && npm run build # サーバービルド + +# データベース +docker compose up -d postgres # ローカル DB 起動 +cd server && npx prisma migrate dev # マイグレーション実行 +cd server && npx prisma generate # Prisma Client 再生成 +``` + +## アーキテクチャ + +### モノレポ構成 + +``` +common/ 共有 Zod スキーマと色ユーティリティ +client/ React 19 + Vite フロントエンド +server/ Hono + Prisma バックエンド +``` + +### 型安全な API クライアント + +クライアントは `hono/client` による RPC で型安全にサーバーと通信する。サーバーの `AppType` を直接 import することで、エンドポイントの型が共有される。 + +```ts +// client/src/pages/eventId/Submission.tsx +import type { AppType } from "../../../../server/src/main"; +const client = hc(API_ENDPOINT); +``` + +REST fetch ではなく `client.projects[":projectId"].$get(...)` 形式で呼び出す。 + +### 認証(browserId) + +ユーザーアカウントなし。サーバーの `browserIdMiddleware` が、初回アクセス時に UUID を生成して署名付き Cookie として保存し、以降のリクエストでは Cookie から `browserId` を復元して `c.set("browserId", ...)` に格納する。Express から Hono への移行時に Cookie の署名形式が変わったため、ミドルウェアは両形式に対応している(`server/src/middleware/browserId.ts`)。 + +### カレンダーの状態管理(CalendarMatrix) + +`client/src/lib/CalendarMatrix.ts` に核となるデータ構造がある。カレンダーの選択状態は 2D 行列で管理される(行=日、列=15分単位のスロット): + +- **`EditingMatrix`**: 自分の入力中スロット。セル値は `participationOptionId`(文字列)。 +- **`ViewingMatrix`**: 全ゲストの既存スロット。セル値は `{ [guestId]: optionId }` のレコード。 + +どちらも `getSlots()` で連続するセルをまとめてイベント単位に変換(run-length encoding 的な処理)して返す。 + +`Calendar.tsx` はこのマトリックスを `useRef` で保持し、スロット変更時に `editingSlots → matrix → CalendarEvent[]` の順で再計算して FullCalendar に渡す。 + +### dayjs のインポート制限 + +`dayjs` は `lib/dayjs.ts` 経由で使うこと(Biome のカスタムルールで強制)。このファイルで UTC・timezone プラグインを有効化し、デフォルトタイムゾーンを `Asia/Tokyo` に設定している。カレンダーの座標計算がタイムゾーンに依存するため直接 import すると不具合が起きる。 + +```ts +// NG +import dayjs from "dayjs"; +// OK +import dayjs from "../lib/dayjs"; +``` + +### 参加形態(ParticipationOption)の流れ + +- 作成時: フロントエンドで `crypto.randomUUID()` を生成して `id` を確定。サーバーは受け取った id をそのまま使用。 +- デフォルト: 参加形態が0件の場合は `common/colors.ts` の `DEFAULT_PARTICIPATION_OPTION` を使用してデフォルトを自動作成(label: "参加", color: "#0F82B1")。 +- 削除制限: Slot が紐づいている参加形態は削除不可(サーバー側で検証)。 + +### Cloudflare Pages Functions + +`client/functions/[[path]].ts` が catch-all ルートとして動作し、`/e/:eventId` パターンの OG メタタグを動的に書き換える。ローカル確認は `npm run dev:functions` を使う(`npm run dev:client` の Vite dev server では Functions は動作しない)。 diff --git a/client/package.json b/client/package.json index 8bfb0be..6ac4ae6 100644 --- a/client/package.json +++ b/client/package.json @@ -13,15 +13,9 @@ }, "dependencies": { "@cloudflare/pages-plugin-vercel-og": "^0.1.2", - "@fullcalendar/core": "^6.1.15", - "@fullcalendar/interaction": "^6.1.15", - "@fullcalendar/moment-timezone": "^6.1.20", - "@fullcalendar/react": "^6.1.15", - "@fullcalendar/timegrid": "^6.1.15", "@hookform/resolvers": "^4.1.3", "@tailwindcss/vite": "^4.0.13", "dayjs": "^1.11.13", - "moment-timezone": "^0.5.48", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", diff --git a/client/public/_headers b/client/public/_headers index a632347..2743d62 100644 --- a/client/public/_headers +++ b/client/public/_headers @@ -1,3 +1,9 @@ +https://itsuhima.utcode.net/e/* + X-Robots-Tag: noindex + +https://itsuhima.utcode.net/og/* + X-Robots-Tag: noindex + https://itsuhima-staging.utcode.net/* X-Robots-Tag: noindex diff --git a/client/src/App.tsx b/client/src/App.tsx index b1b1125..f0bb14a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -40,7 +40,6 @@ export default function App() { } /> } /> } /> - }> } /> diff --git a/client/src/components/Calendar.tsx b/client/src/components/Calendar.tsx index 6e8b2d6..6bae9b2 100644 --- a/client/src/components/Calendar.tsx +++ b/client/src/components/Calendar.tsx @@ -1,22 +1,8 @@ -import type { - DateSelectArg, - DateSpanApi, - DayHeaderContentArg, - EventContentArg, - EventInput, - EventMountArg, - SlotLabelContentArg, -} from "@fullcalendar/core/index.js"; -import interactionPlugin from "@fullcalendar/interaction"; -import momentTimezonePlugin from "@fullcalendar/moment-timezone"; -import FullCalendar from "@fullcalendar/react"; -import timeGridPlugin from "@fullcalendar/timegrid"; -import type React from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Tooltip } from "react-tooltip"; -import useCalendarScrollBlock from "../hooks/useCalendarScrollBlock"; +import { useLongPressDrag } from "../hooks/useLongPressDrag"; import { EditingMatrix, ViewingMatrix } from "../lib/CalendarMatrix"; -import dayjs, { type Dayjs } from "../lib/dayjs"; +import type { Dayjs } from "../lib/dayjs"; import type { EditingSlot } from "../pages/eventId/Submission"; type AllowedRange = { @@ -37,21 +23,6 @@ type ParticipationOption = { color: string; }; -type CalendarEventExtendedProps = { - optionBreakdown?: { - optionId: string; - optionLabel: string; - color: string; - members: string[]; - count: number; - }[]; - backgroundStyle?: string; -}; - -type CalendarEvent = Pick & { - extendedProps?: CalendarEventExtendedProps; -}; - type Props = { startDate: Dayjs; endDate: Dayjs; @@ -66,28 +37,29 @@ type Props = { onChangeEditingSlots: (slots: EditingSlot[]) => void; }; -const OPACITY = 0.2; -const PRIMARY_RGB = [15, 130, 177]; - -/** - * 長押しでドラッグ開始とみなすまでの遅延時間 (ms) - * - FullCalendar で選択イベントが点灯し始めるまでの時間。 - * - また、これ以上の時間で押し続けるとドラッグ操作として扱われ、スクロールを無効化する - */ -const LONG_PRESS_DELAY = 150; +type Preview = { + fromDay: number; + fromSlot: number; + toDay: number; + toSlot: number; + isDeletion: boolean; +}; -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"; +const MIN_DAY_WIDTH = 56; +const MIN_SLOT_HEIGHT = 14; +const HEADER_HEIGHT = 40; +const TIME_COL_WIDTH = 40; +const EDGE_ZONE = 60; +const MAX_SCROLL_SPEED = 8; +const OPACITY = 0.2; +const PRIMARY_RGB: [number, number, number] = [15, 130, 177]; // 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]); + : PRIMARY_RGB; } export const Calendar = ({ @@ -104,400 +76,409 @@ export const Calendar = ({ onChangeEditingSlots, }: Props) => { const countDays = endDate.startOf("day").diff(startDate.startOf("day"), "day") + 1; - // TODO: +1 は不要かも - const editingMatrixRef = useRef(new EditingMatrix(countDays + 1, startDate)); - const viewingMatrixRef = useRef(new ViewingMatrix(countDays + 1, startDate)); - - // TODO: 現在は最初の選択範囲のみ。FullCalendar の制約により、複数の allowedRanges には対応できないため、のちに selectAllow などで独自実装が必要 - const tmpAllowedRange = allowedRanges[0] ?? { - startTime: dayjs.utc().tz().set("hour", 0).set("minute", 0).toDate(), - endTime: dayjs.utc().tz().set("hour", 23).set("minute", 59).toDate(), - }; - const calendarRef = useRef(null); - const isSelectionDeleting = useRef(null); - - // FullCalendar の state - const [events, setEvents] = useState([]); - const [tooltipKey, setTooltipKey] = useState(0); - - // editingSlots/viewingSlots → matrix → events + const allowedRange = allowedRanges[0] ?? { + startTime: startDate.startOf("day"), + endTime: startDate.startOf("day").add(23, "hour").add(59, "minute"), + }; + const slotStartMinutes = allowedRange.startTime.hour() * 60 + allowedRange.startTime.minute(); + const slotEndMinutes = allowedRange.endTime.hour() * 60 + allowedRange.endTime.minute(); + const slotCount = Math.ceil((slotEndMinutes - slotStartMinutes) / 15); + + const gridHeight = slotCount * MIN_SLOT_HEIGHT; + const innerWidth = TIME_COL_WIDTH + countDays * MIN_DAY_WIDTH; + + const editingMatrixRef = useRef(new EditingMatrix(countDays, startDate)); + const [slots, setSlots] = useState(() => editingMatrixRef.current.getSlots()); + const [preview, setPreview] = useState(null); + + const scrollRef = useRef(null); + const gridRef = useRef(null); + const dragStart = useRef<{ day: number; slot: number } | null>(null); + const isDeletion = useRef(false); + const previewRef = useRef(null); + const autoScrollRef = useRef(null); + const pointerXRef = useRef(0); + const pointerYRef = useRef(0); + const updatePreviewRef = useRef<(x: number, y: number) => void>(() => {}); + + // editingSlots prop → matrix → local state useEffect(() => { - // editingSlots → editingMatrix editingMatrixRef.current.clear(); - editingSlots.forEach((slot) => { - const { from, to } = normalizeVertexes(slot.from, slot.to); - editingMatrixRef.current.setRange(from, to, slot.participationOptionId); - }); - - viewingMatrixRef.current.clear(); - - viewingSlots.forEach((slot) => { - const { from, to } = normalizeVertexes(slot.from, slot.to); - viewingMatrixRef.current.setGuestRange(from, to, slot.guestId, slot.optionId); - }); - - // matrix → events - 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.format(), - end: slot.to.format(), - 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) => { - const name = guestIdToName[guestId] || guestId; - return guestIdToComment[guestId] ? `${name} 💬` : name; - }); - 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.format(), - end: slot.to.format(), - color: defaultColor, - display: "background" as const, - extendedProps: { - optionBreakdown, - backgroundStyle, - }, - }); - }); - - setEvents([...editingEvents, ...viewingEvents]); - }, [editingSlots, viewingSlots, guestIdToName, guestIdToComment, 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); + for (const slot of editingSlots) { + editingMatrixRef.current.setRange(slot.from, slot.to, slot.participationOptionId); } - - // 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 () => { - // クリーンアップは不要(次回の更新で上書きされるため) + setSlots(editingMatrixRef.current.getSlots()); + }, [editingSlots]); + + // viewingSlots → ViewingMatrix → rendered slots + const computedViewingSlots = 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(); + }, [viewingSlots, countDays, startDate]); + + // セル座標変換ヘルパー(毎レンダーで最新クロージャを利用) + const xyToCell = (x: number, y: number) => { + const el = gridRef.current; + if (!el) return null; + const r = el.getBoundingClientRect(); + return { + day: Math.min(Math.max(Math.floor(((x - r.left) / r.width) * countDays), 0), countDays - 1), + slot: Math.min(Math.max(Math.floor(((y - r.top) / r.height) * slotCount), 0), slotCount - 1), }; - }, [events]); - - // カレンダー外までドラッグした際に選択を解除するためのイベントハンドラを登録 - useEffect(() => { - const handleMouseUp = (e: MouseEvent | TouchEvent) => { - const calendarEl = document.getElementById("ih-cal-wrapper"); + }; - const target = - e instanceof MouseEvent - ? e.target - : document.elementFromPoint(e.changedTouches[0].clientX, e.changedTouches[0].clientY); + const cellToDayjs = (day: number, slot: number) => + startDate + .startOf("day") + .add(day, "day") + .add(slotStartMinutes + slot * 15, "minute"); + + const toSlotIdx = (dt: Dayjs) => (dt.hour() * 60 + dt.minute() - slotStartMinutes) / 15; + + updatePreviewRef.current = (x: number, y: number) => { + const cell = xyToCell(x, y); + const s = dragStart.current; + if (!cell || !s) return; + const p: Preview = { + fromDay: Math.min(s.day, cell.day), + fromSlot: Math.min(s.slot, cell.slot), + toDay: Math.max(s.day, cell.day), + toSlot: Math.max(s.slot, cell.slot), + isDeletion: isDeletion.current, + }; + previewRef.current = p; + setPreview(p); + }; - const isExternal = calendarEl && !calendarEl.contains(target as Node); + const stopAutoScroll = () => { + if (autoScrollRef.current !== null) { + cancelAnimationFrame(autoScrollRef.current); + autoScrollRef.current = null; + } + }; - if (isSelectionDeleting.current !== null && calendarEl && isExternal) { - isSelectionDeleting.current = null; - const existingSelection = calendarRef.current?.getApi()?.getEventById(SELECT_EVENT); - if (existingSelection) { - existingSelection.remove(); - } + const startAutoScroll = () => { + if (autoScrollRef.current !== null) return; + const loop = () => { + const sc = scrollRef.current; + if (!sc) { + autoScrollRef.current = null; + return; + } + const { left, right, top, bottom } = sc.getBoundingClientRect(); + const x = pointerXRef.current; + const y = pointerYRef.current; + let scrolled = false; + if (x < left + EDGE_ZONE) { + sc.scrollLeft -= ((left + EDGE_ZONE - x) / EDGE_ZONE) * MAX_SCROLL_SPEED; + scrolled = true; + } else if (x > right - EDGE_ZONE) { + sc.scrollLeft += ((x - (right - EDGE_ZONE)) / EDGE_ZONE) * MAX_SCROLL_SPEED; + scrolled = true; + } + if (y < top + EDGE_ZONE) { + sc.scrollTop -= ((top + EDGE_ZONE - y) / EDGE_ZONE) * MAX_SCROLL_SPEED; + scrolled = true; + } else if (y > bottom - EDGE_ZONE) { + sc.scrollTop += ((y - (bottom - EDGE_ZONE)) / EDGE_ZONE) * MAX_SCROLL_SPEED; + scrolled = true; + } + if (scrolled) { + updatePreviewRef.current(pointerXRef.current, pointerYRef.current); + autoScrollRef.current = requestAnimationFrame(loop); + } else { + autoScrollRef.current = null; } }; - document.addEventListener("mouseup", handleMouseUp); - document.addEventListener("touchend", handleMouseUp); + autoScrollRef.current = requestAnimationFrame(loop); + }; + + useEffect(() => { return () => { - document.removeEventListener("mouseup", handleMouseUp); - document.removeEventListener("touchend", handleMouseUp); + const id = autoScrollRef.current; + if (id !== null) cancelAnimationFrame(id); + autoScrollRef.current = null; }; }, []); - useCalendarScrollBlock(LONG_PRESS_DELAY); - - const pageCount = Math.ceil(countDays / 7); - - const headerToolbar = useMemo( - () => - pageCount >= 2 - ? { - left: "prev", - right: "next", - } - : false, - [pageCount], - ); - - const views = useMemo( - () => ({ - timeGrid: { - type: "timeGrid", - duration: { days: Math.min(countDays, 7) }, - dayHeaderContent: (args: DayHeaderContentArg) => { - return ( -
-
{dayjs.utc(args.date).tz().format("M/D")}
-
{dayjs.utc(args.date).tz().format("(ddd)")}
-
- ); - }, - slotLabelContent: (args: SlotLabelContentArg) => { - // -translate-y-1/2 で時刻ラベルをグリッド線上に中央揃えする - return
{dayjs.utc(args.date).tz().format("HH:mm")}
; - }, - slotLabelInterval: "00:30:00", - validRange: { - start: startDate.format(), - end: endDate.format(), - }, - expandRows: true, - }, - }), - [countDays, startDate, endDate], - ); - - const handleSelectAllow = useCallback( - // 選択中に選択範囲を表示する - (info: DateSpanApi) => { - if (!editMode) return false; - return displaySelection( - info, - isSelectionDeleting, - calendarRef, - editingMatrixRef, - participationOptions, - currentParticipationOptionId, - ); - }, - [editMode, participationOptions, currentParticipationOptionId], - ); - - const handleSelect = useCallback( - // 選択が完了した際に編集する - (info: DateSelectArg) => { + useLongPressDrag(gridRef, { + onStart: (x, y) => { if (!editMode) return; - - const { from, to } = normalizeVertexes(dayjs.utc(info.start).tz(), dayjs.utc(info.end).tz()); - - if (isSelectionDeleting.current === null) return; - const isDeletion = isSelectionDeleting.current; - - // matrix を更新 - 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); - - // 選択範囲をクリア - const calendarApi = calendarRef.current?.getApi(); - const existingSelection = calendarApi?.getEventById(SELECT_EVENT); - if (existingSelection) { - existingSelection.remove(); + const cell = xyToCell(x, y); + if (!cell) return; + isDeletion.current = editingMatrixRef.current.getIsSlotExist(cellToDayjs(cell.day, cell.slot)); + dragStart.current = cell; + scrollRef.current?.classList.add("scrollbar-hidden"); + }, + onMove: (x, y) => { + if (!dragStart.current) return; + pointerXRef.current = x; + pointerYRef.current = y; + updatePreviewRef.current(x, y); + const sc = scrollRef.current; + if (sc) { + const { left, right, top, bottom } = sc.getBoundingClientRect(); + if (x < left + EDGE_ZONE || x > right - EDGE_ZONE || y < top + EDGE_ZONE || y > bottom - EDGE_ZONE) { + startAutoScroll(); + } } - isSelectionDeleting.current = null; }, - [editMode, onChangeEditingSlots, currentParticipationOptionId], - ); + onEnd: () => { + stopAutoScroll(); + scrollRef.current?.classList.remove("scrollbar-hidden"); + const p = + previewRef.current ?? + (dragStart.current + ? { + fromDay: dragStart.current.day, + fromSlot: dragStart.current.slot, + toDay: dragStart.current.day, + toSlot: dragStart.current.slot, + isDeletion: isDeletion.current, + } + : null); + if (p) { + for (let d = p.fromDay; d <= p.toDay; d++) { + editingMatrixRef.current.setRange( + cellToDayjs(d, p.fromSlot), + cellToDayjs(d, p.toSlot + 1), + isDeletion.current ? null : currentParticipationOptionId, + ); + } + const newSlots = editingMatrixRef.current.getSlots().map((slot) => ({ + from: slot.from, + to: slot.to, + participationOptionId: slot.optionId, + })); + onChangeEditingSlots(newSlots); + previewRef.current = null; + setPreview(null); + } + dragStart.current = null; + }, + }); - const handleEventDidMount = useCallback((info: EventMountArg) => { - if (info.event.classNames.includes(EDITING_EVENT)) { - // 既存の event 上で選択できるようにするため。 - info.el.style.pointerEvents = "none"; + const pct = (fromDay: number, fromSlot: number, daySpan: number, slotSpan: number) => ({ + left: `${(fromDay / countDays) * 100}%`, + top: `${(fromSlot / slotCount) * 100}%`, + width: `${(daySpan / countDays) * 100}%`, + height: `${(slotSpan / slotCount) * 100}%`, + }); - 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 currentOption = participationOptions.find((o) => o.id === currentParticipationOptionId); + const currentColor = currentOption?.color ?? `rgb(${PRIMARY_RGB.join(",")})`; + + const halfHourCount = Math.floor((slotEndMinutes - slotStartMinutes) / 30) + 1; - 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 ( -
- {optionBreakdown.map((breakdown, index) => { - const tooltipContent = ` -
-
${breakdown.optionLabel}
-
    - ${breakdown.members.map((name) => `
  • ${name}
  • `).join("")} -
-
- `; - const position = ((index + 0.5) / optionBreakdown.length) * 100; - - return ( -
+ return ( +
+
+ {/* コーナーオーバーレイ: 縦横どちらにスクロールしても左上に固定 */} +
+ + {/* 日付ヘッダー行(sticky: 縦スクロール時に上部固定) */} +
+
+
+ {Array.from({ length: countDays }, (_, i) => { + const d = startDate.add(i, "day"); + return (
- {breakdown.count} + {d.format("M/D")} + {d.format("(ddd)")}
-
- ); - })} + ); + })} +
- ); - } - if (info.event.classNames.includes(EDITING_EVENT)) { - return ( -
{`${dayjs.utc(info.event.start).tz().format("HH:mm")} - ${dayjs.utc(info.event.end).tz().format("HH:mm")}`}
- ); - } - }, []); - /** - * カレンダーで表示される日付範囲が変更されると呼ばれる。 https://fullcalendar.io/docs/datesSet - */ - const handleDatesSet = useCallback(() => { - setTooltipKey((prev) => prev + 1); - }, []); + {/* コンテンツ行: 時刻ラベル + グリッド本体 */} +
+ {/* 時刻ラベル列(sticky: 横スクロール時に左端固定) */} +
+
+ {Array.from({ length: halfHourCount }, (_, i) => { + const totalMin = slotStartMinutes + i * 30; + const hour = Math.floor(totalMin / 60); + const min = totalMin % 60; + return ( +
+ {`${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")}`} +
+ ); + })} +
+
+ + {/* グリッド本体 */} +
+
+ + {/* 閲覧スロット(他ゲストの登録済み時間) */} + {computedViewingSlots.map((slot) => { + const dayIdx = slot.from.startOf("day").diff(startDate.startOf("day"), "day"); + const fromSlot = toSlotIdx(slot.from); + const toSlot = toSlotIdx(slot.to); + if (dayIdx < 0 || dayIdx >= countDays || fromSlot < 0 || toSlot <= fromSlot) return null; + + const optionGroups = new Map(); + for (const [guestId, optionId] of Object.entries(slot.guestIdToOptionId)) { + const group = optionGroups.get(optionId); + if (group) { + group.push(guestId); + } else { + optionGroups.set(optionId, [guestId]); + } + } + + const breakdown = participationOptions + .filter((opt) => optionGroups.has(opt.id)) + .map((opt) => { + const guestIds = optionGroups.get(opt.id) ?? []; + const opacity = 1 - (1 - OPACITY) ** guestIds.length; + return { ...opt, guestIds, opacity }; + }); + + let background: string; + if (breakdown.length === 1) { + const [r, g, b] = hexToRgb(breakdown[0].color); + 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); + return `rgba(${r},${g},${b},${bd.opacity.toFixed(3)}) ${j * w}%, rgba(${r},${g},${b},${bd.opacity.toFixed(3)}) ${(j + 1) * w}%`; + }) + .join(", "); + background = `linear-gradient(90deg, ${stops})`; + } else { + const [r, g, b] = PRIMARY_RGB; + background = `rgba(${r},${g},${b},${OPACITY})`; + } + + return ( +
+
+ {breakdown.map((bd, j) => { + const position = ((j + 0.5) / breakdown.length) * 100; + const tooltipContent = ` +
+
${bd.label}
+
    + ${bd.guestIds + .map((guestId) => { + const name = guestIdToName[guestId] ?? guestId; + return `
  • ${guestIdToComment[guestId] ? `${name} 💬` : name}
  • `; + }) + .join("")} +
+
+ `; + return ( +
+ + {bd.guestIds.length} + +
+ ); + })} +
+
+ ); + })} - return ( -
- + {/* 編集中スロット(自分の登録済み時間) */} + {slots.map((slot) => { + const dayIdx = slot.from.startOf("day").diff(startDate.startOf("day"), "day"); + const fromSlot = toSlotIdx(slot.from); + const toSlot = toSlotIdx(slot.to); + if (dayIdx < 0 || dayIdx >= countDays) return null; + + const option = participationOptions.find((o) => o.id === slot.optionId); + const color = option?.color ?? `rgb(${PRIMARY_RGB.join(",")})`; + const [r, g, b] = hexToRgb(color); + + return ( +
+ ); + })} + + {/* ドラッグ中プレビュー */} + {preview && + (() => { + const [r, g, b] = preview.isDeletion ? [239, 68, 68] : hexToRgb(currentColor); + return ( +
+ ); + })()} +
+
+
); }; - -function displaySelection( - info: DateSpanApi, - isSelectionDeleting: React.RefObject, - calendarRef: React.RefObject, - myMatrixRef: React.RefObject, - participationOptions: ParticipationOption[], - currentParticipationOptionId: string, -) { - // 選択範囲の表示 - // 通常の selection では矩形選択ができないため、イベントを作成することで選択範囲を表現している。 - // https://github.com/fullcalendar/fullcalendar/issues/4119 - - const { from, to } = normalizeVertexes(dayjs.utc(info.start).tz(), dayjs.utc(info.end).tz()); - - if (isSelectionDeleting.current === null) { - // ドラッグ開始地点が既存の自分のイベントなら削除モード、そうでなければ追加モードとする。 - // isSelectionDeleting は select の発火時 (つまり、ドラッグが終了した際) に null にリセットされる。 - isSelectionDeleting.current = myMatrixRef.current.getIsSlotExist(from); - } - - if (!calendarRef.current) return false; - const calendarApi = calendarRef.current.getApi(); - - // 既存の選択範囲をクリア - const existingSelection = calendarApi.getEventById(SELECT_EVENT); - if (existingSelection) { - existingSelection.remove(); - } - - // 現在選択されている参加形態の色を取得 - 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, - startTime: from.format("HH:mm"), - endTime: to.format("HH:mm"), - startRecur: from.startOf("day").format("YYYY-MM-DD"), - endRecur: to.startOf("day").add(1, "day").format("YYYY-MM-DD"), - display: "background", - backgroundColor: backgroundColor, - borderColor: borderColor, - }); - return true; -} - -/** - * 矩形選択の始点・終点を、左上(=日付も時刻も早い)・右下(=日付も時刻も遅い)に正規化して返す。 - * FullCalendar の返す selection を矩形選択に利用するために使用。 - * なお FullCalendar は逆向きに選択した場合、時間順に入れ替えて from, to を渡してくるので from < to は常に満たされる - */ -function normalizeVertexes(from: Dayjs, to: Dayjs) { - if (!from.isBefore(to)) { - throw new Error("from < to is required"); - } - const fromTime = from.hour() * 60 + from.minute(); - const toTime = to.hour() * 60 + to.minute(); - - if (fromTime < toTime) { - // from の時刻 < to の時刻なら、そのまま返す - return { from, to }; - } - // from の時刻 >= to の時刻の場合、矩形選択の左上と右上の点を算出しそれを新たな from, to として返す。 - // fullcalendar は [from, to) で返してくるので、swap 時はそれぞれ 1 セル (=15分) ずらすことが必要。 - const newFrom = from.startOf("day").add(toTime, "minute").subtract(15, "minute"); - const newTo = to.startOf("day").add(fromTime, "minute").add(15, "minute"); - return { from: newFrom, to: newTo }; -} diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 6966c40..41860a5 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -3,18 +3,26 @@ import { LuMenu, LuX } from "react-icons/lu"; import { NavLink } from "react-router"; import { EXTERNAL_LINKS } from "../constants/links"; -export default function Header() { +export default function Header({ compact = false }: { compact?: boolean }) { const [isMenuOpen, setIsMenuOpen] = useState(false); return (
-
+
- イツヒマ - イツヒマ - - (アルファ版) + イツヒマ + + イツヒマ + {!compact && ( + + (アルファ版) + + )}