From c1e4df497f2afb5af26722090940705c1f75fcd6 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:22:41 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E7=8B=AC=E8=87=AA=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E3=82=AB=E3=83=AC=E3=83=B3=E3=83=80=E3=83=BC=E3=82=B3?= =?UTF-8?q?=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=8D=E3=83=B3=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 84 ++++++ client/package.json | 1 + client/src/App.tsx | 2 + client/src/hooks/useLongPressDrag.ts | 127 ++++++++ client/src/index.css | 8 + client/src/pages/Sandbox.tsx | 426 +++++++++++++++++++++++++++ package-lock.json | 19 ++ 7 files changed, 667 insertions(+) create mode 100644 CLAUDE.md create mode 100644 client/src/hooks/useLongPressDrag.ts create mode 100644 client/src/pages/Sandbox.tsx 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..1b9b0ab 100644 --- a/client/package.json +++ b/client/package.json @@ -20,6 +20,7 @@ "@fullcalendar/timegrid": "^6.1.15", "@hookform/resolvers": "^4.1.3", "@tailwindcss/vite": "^4.0.13", + "@use-gesture/react": "^10.3.1", "dayjs": "^1.11.13", "moment-timezone": "^0.5.48", "react": "^19.0.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index b1b1125..4ff4d03 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,6 +4,7 @@ import HomePage from "./pages/Home.tsx"; import LandingPage from "./pages/Landing.tsx"; import NotFoundPage from "./pages/NotFound.tsx"; import ProjectPage from "./pages/Project.tsx"; +import SandboxPage from "./pages/Sandbox.tsx"; /** * Nano ID 形式の正規表現。 @@ -40,6 +41,7 @@ export default function App() { } /> } /> } /> + } /> }> diff --git a/client/src/hooks/useLongPressDrag.ts b/client/src/hooks/useLongPressDrag.ts new file mode 100644 index 0000000..37da784 --- /dev/null +++ b/client/src/hooks/useLongPressDrag.ts @@ -0,0 +1,127 @@ +import { type RefObject, useEffect, useRef } from "react"; + +type Callbacks = { + onStart: (x: number, y: number) => void; + onMove: (x: number, y: number) => void; + onEnd: () => void; +}; + +/** + * グリッド上でのドラッグ操作を管理するフック。 + * + * タッチ: LONG_PRESS_DELAY ms 静止後にドラッグ開始。それ以前に動いた場合はスクロールとして扱う。 + * マウス: mousedown で即座にドラッグ開始。 + * + * touchmove を passive:false で登録することで、ドラッグ確定後のみ preventDefault() でスクロールを抑制する。 + */ +export function useLongPressDrag( + ref: RefObject, + callbacks: Callbacks, + { delay = 150, moveThreshold = 5 }: { delay?: number; moveThreshold?: number } = {}, +) { + const cb = useRef(callbacks); + cb.current = callbacks; + + useEffect(() => { + const el = ref.current; + if (!el) return; + + type Phase = "idle" | "waiting" | "dragging" | "scrolling"; + let phase: Phase = "idle"; + let timer: ReturnType | null = null; + let startX = 0; + let startY = 0; + let lastTouchEndTime = 0; + + const clearTimer = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + + // --- タッチ --- + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length !== 1) return; + const t = e.touches[0]; + startX = t.clientX; + startY = t.clientY; + phase = "waiting"; + clearTimer(); + timer = setTimeout(() => { + phase = "dragging"; + cb.current.onStart(startX, startY); + cb.current.onMove(startX, startY); // 長押し確定時点で単セルプレビューを表示 + }, delay); + }; + + const onTouchMove = (e: TouchEvent) => { + if (phase === "idle" || phase === "scrolling") return; + const t = e.touches[0]; + if (phase === "waiting") { + const dx = Math.abs(t.clientX - startX); + const dy = Math.abs(t.clientY - startY); + if (dx > moveThreshold || dy > moveThreshold) { + clearTimer(); + phase = "scrolling"; + } + return; + } + // dragging: スクロールを止めてドラッグ座標を通知 + e.preventDefault(); + cb.current.onMove(t.clientX, t.clientY); + }; + + const onTouchEnd = () => { + lastTouchEndTime = Date.now(); + clearTimer(); + if (phase === "dragging") { + cb.current.onEnd(); + } else if (phase === "waiting") { + // 長押し前に離した = タップ: onStart → onEnd を即時呼び出し + cb.current.onStart(startX, startY); + cb.current.onEnd(); + } + phase = "idle"; + }; + + // --- マウス --- + const onMouseDown = (e: MouseEvent) => { + // タッチ後にブラウザが生成する合成 mousedown を無視する + if (Date.now() - lastTouchEndTime < 500) return; + phase = "dragging"; + startX = e.clientX; + startY = e.clientY; + cb.current.onStart(e.clientX, e.clientY); + }; + + const onDocMouseMove = (e: MouseEvent) => { + if (phase !== "dragging") return; + cb.current.onMove(e.clientX, e.clientY); + }; + + const onDocMouseUp = () => { + if (phase === "dragging") cb.current.onEnd(); + phase = "idle"; + }; + + el.addEventListener("touchstart", onTouchStart, { passive: true }); + el.addEventListener("touchmove", onTouchMove, { passive: false }); + el.addEventListener("touchend", onTouchEnd, { passive: true }); + el.addEventListener("touchcancel", onTouchEnd, { passive: true }); + el.addEventListener("mousedown", onMouseDown); + document.addEventListener("mousemove", onDocMouseMove); + document.addEventListener("mouseup", onDocMouseUp); + + return () => { + clearTimer(); + el.removeEventListener("touchstart", onTouchStart); + el.removeEventListener("touchmove", onTouchMove); + el.removeEventListener("touchend", onTouchEnd); + el.removeEventListener("touchcancel", onTouchEnd); + el.removeEventListener("mousedown", onMouseDown); + document.removeEventListener("mousemove", onDocMouseMove); + document.removeEventListener("mouseup", onDocMouseUp); + }; + }, [ref, delay, moveThreshold]); +} diff --git a/client/src/index.css b/client/src/index.css index cce2b31..191eef6 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -177,3 +177,11 @@ body { #root { background-color: var(--color-base-100); } + +/* ドラッグ中にスクロールコンテナのスクロールバーを隠す */ +.scrollbar-hidden::-webkit-scrollbar { + display: none; +} +.scrollbar-hidden { + scrollbar-width: none; +} diff --git a/client/src/pages/Sandbox.tsx b/client/src/pages/Sandbox.tsx new file mode 100644 index 0000000..9918edf --- /dev/null +++ b/client/src/pages/Sandbox.tsx @@ -0,0 +1,426 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useLongPressDrag } from "../hooks/useLongPressDrag"; +import { EditingMatrix, ViewingMatrix } from "../lib/CalendarMatrix"; +import dayjs from "../lib/dayjs"; + +const START_DATE = dayjs().startOf("day"); +const DAY_COUNT = 14; +const MIN_DAY_WIDTH = 56; // px per day column +const MIN_SLOT_HEIGHT = 14; // px per 15-min slot +const SLOT_START_HOUR = 9; +const SLOT_END_HOUR = 21; +const SLOT_COUNT = (SLOT_END_HOUR - SLOT_START_HOUR) * 4; +const GRID_HEIGHT = SLOT_COUNT * MIN_SLOT_HEIGHT; // 48 * 14 = 672px +const HEADER_HEIGHT = 40; // px — date header row +const TIME_COL_WIDTH = 40; // px — time label column +const INNER_WIDTH = TIME_COL_WIDTH + DAY_COUNT * MIN_DAY_WIDTH; +const OPTION_ID = "sandbox"; +const OPACITY = 0.2; +const EDGE_ZONE = 60; // px from scroll edge to trigger auto-scroll +const MAX_SCROLL_SPEED = 8; // px per animation frame + +// ダミーの参加形態 +const VIEWING_OPTIONS = [ + { id: "opt-1", label: "対面", color: "#0F82B1" }, + { id: "opt-2", label: "オンライン", color: "#10B981" }, +]; + +// ダミーのゲスト +const GUESTS: Record = { + "guest-1": "田中", + "guest-2": "鈴木", + "guest-3": "佐藤", +}; + +// ダミーの登録済みスロット +const DUMMY_VIEWING_SLOTS = [ + { guestId: "guest-1", optionId: "opt-1", from: START_DATE.hour(10), to: START_DATE.hour(12) }, + { guestId: "guest-2", optionId: "opt-1", from: START_DATE.hour(11), to: START_DATE.hour(13) }, + { guestId: "guest-3", optionId: "opt-2", from: START_DATE.hour(10), to: START_DATE.hour(11) }, + { guestId: "guest-1", optionId: "opt-2", from: START_DATE.add(1, "day").hour(10), to: START_DATE.add(1, "day").hour(12) }, + { guestId: "guest-2", optionId: "opt-1", from: START_DATE.add(1, "day").hour(10), to: START_DATE.add(1, "day").hour(11) }, + { guestId: "guest-3", optionId: "opt-1", from: START_DATE.add(2, "day").hour(14), to: START_DATE.add(2, "day").hour(16) }, + { guestId: "guest-1", optionId: "opt-1", from: START_DATE.add(2, "day").hour(14), to: START_DATE.add(2, "day").hour(15) }, +]; + +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)] + : [15, 130, 177]; +} + +type Preview = { + fromDay: number; + fromSlot: number; + toDay: number; + toSlot: number; + isDeletion: boolean; +}; + +export default function SandboxPage() { + const matrixRef = useRef(new EditingMatrix(DAY_COUNT, START_DATE)); + const [slots, setSlots] = useState(() => matrixRef.current.getSlots()); + const [preview, setPreview] = useState(null); + const gridRef = useRef(null); + const scrollRef = 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>(() => {}); + + // ViewingMatrix をダミーデータで初期化(静的なので一度だけ) + const viewingSlots = useMemo(() => { + const matrix = new ViewingMatrix(DAY_COUNT, START_DATE); + for (const s of DUMMY_VIEWING_SLOTS) { + matrix.setGuestRange(s.from, s.to, s.guestId, s.optionId); + } + return matrix.getSlots(); + }, []); + + 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) * DAY_COUNT), 0), DAY_COUNT - 1), + slot: Math.min(Math.max(Math.floor(((y - r.top) / r.height) * SLOT_COUNT), 0), SLOT_COUNT - 1), + }; + }; + + const cellToDayjs = (day: number, slot: number) => + START_DATE.add(day, "day") + .hour(SLOT_START_HOUR) + .minute(0) + .add(slot * 15, "minute"); + + // 毎レンダーで最新のクロージャに更新される。auto-scroll ループから呼び出す。 + 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 stopAutoScroll = () => { + if (autoScrollRef.current !== null) { + cancelAnimationFrame(autoScrollRef.current); + autoScrollRef.current = null; + } + }; + + 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; + } + }; + autoScrollRef.current = requestAnimationFrame(loop); + }; + + // アンマウント時に rAF をキャンセル + useEffect(() => { + return () => { + const id = autoScrollRef.current; + if (id !== null) cancelAnimationFrame(id); + autoScrollRef.current = null; + }; + }, []); + + useLongPressDrag(gridRef, { + onStart: (x, y) => { + const cell = xyToCell(x, y); + if (!cell) return; + isDeletion.current = matrixRef.current.getIsSlotExist(cellToDayjs(cell.day, cell.slot)); + dragStart.current = cell; + scrollRef.current?.classList.add("scrollbar-hidden"); + }, + onMove: (x, y) => { + 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(); + } + } + }, + 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++) { + matrixRef.current.setRange( + cellToDayjs(d, p.fromSlot), + cellToDayjs(d, p.toSlot + 1), + isDeletion.current ? null : OPTION_ID, + ); + } + setSlots(matrixRef.current.getSlots()); + previewRef.current = null; + setPreview(null); + } + dragStart.current = null; + }, + }); + + // セルインデックス → CSS パーセント位置 + const pct = (fromDay: number, fromSlot: number, daySpan: number, slotSpan: number) => ({ + left: `${(fromDay / DAY_COUNT) * 100}%`, + top: `${(fromSlot / SLOT_COUNT) * 100}%`, + width: `${(daySpan / DAY_COUNT) * 100}%`, + height: `${(slotSpan / SLOT_COUNT) * 100}%`, + }); + + // Dayjs → グリッド内スロットインデックス + const toSlotIdx = (dt: ReturnType) => + (dt.hour() - SLOT_START_HOUR) * 4 + dt.minute() / 15; + + return ( +
+

カレンダー サンドボックス

+ + {/* 凡例 */} +
+ {VIEWING_OPTIONS.map((opt) => ( + + + {opt.label} + + ))} + + 参加者: {Object.values(GUESTS).join(" / ")} + +
+ + {/* + 単一スクロールコンテナ(縦横両対応)。 + 内部の日付ヘッダーは sticky top-0、時刻ラベル列は sticky left-0 で固定。 + */} +
+
+ + {/* + コーナーオーバーレイ: 縦横どちらにスクロールしても左上に固定。 + inner div の直接の子にすることで z-20 がスタッキングコンテキストを + 跨がず日付ヘッダー(z-10)・時刻ラベル(z-10) 両方を覆う。 + marginBottom で後続の日付ヘッダーのレイアウト位置を変えない。 + */} +
+ + {/* 日付ヘッダー行(sticky: 縦スクロール時に上部固定) */} +
+ {/* コーナーオーバーレイに隠れる部分のレイアウトスペーサー */} +
+
+ {Array.from({ length: DAY_COUNT }, (_, i) => { + const d = START_DATE.add(i, "day"); + return ( +
+ {d.format("M/D")} + {d.format("(ddd)")} +
+ ); + })} +
+
+ + {/* コンテンツ行: 時刻ラベル + グリッド本体(mt-2 で 9:00 ラベルの上半分を確保) */} +
+ + {/* 時刻ラベル列(sticky: 横スクロール時に左端固定) */} +
+
+ {Array.from({ length: (SLOT_END_HOUR - SLOT_START_HOUR) * 2 + 1 }, (_, i) => { + const hour = SLOT_START_HOUR + Math.floor(i / 2); + const min = i % 2 === 0 ? "00" : "30"; + return ( +
+ {`${hour}:${min}`} +
+ ); + })} +
+
+ + {/* グリッド本体 */} +
+ {/* 枠線オーバーレイ: ring/border を使わずコンテンツエリアへの影響を防ぐ */} +
+ + {/* 閲覧スロット(他人のイベント) */} + {viewingSlots.map((slot) => { + const dayIdx = slot.from.startOf("day").diff(START_DATE, "day"); + const fromSlot = toSlotIdx(slot.from); + const toSlot = toSlotIdx(slot.to); + if (dayIdx < 0 || dayIdx >= DAY_COUNT || 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]); + } + } + + // VIEWING_OPTIONS の順番でブレークダウンを作成 + const breakdown = VIEWING_OPTIONS.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 }; + }); + + // 背景スタイル: 単色 or グラデーション + let background: string; + if (breakdown.length === 1) { + const [r, g, b] = hexToRgb(breakdown[0].color); + background = `rgba(${r},${g},${b},${breakdown[0].opacity})`; + } else { + 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}) ${j * w}%, rgba(${r},${g},${b},${bd.opacity}) ${(j + 1) * w}%`; + }) + .join(", "); + background = `linear-gradient(90deg, ${stops})`; + } + + return ( +
+ {/* 参加形態ごとにバッジをその色帯の中央に配置 */} +
+ {breakdown.map((bd, j) => { + const position = ((j + 0.5) / breakdown.length) * 100; + return ( +
+ + {bd.guestIds.length} + +
+ ); + })} +
+
+ ); + })} + + {/* 編集中スロット(自分のイベント) */} + {slots.map((slot) => { + const dayIdx = slot.from.startOf("day").diff(START_DATE, "day"); + const fromSlot = toSlotIdx(slot.from); + const toSlot = toSlotIdx(slot.to); + return ( +
+ ); + })} + + {/* ドラッグ中プレビュー */} + {preview && ( +
+ )} +
+
+
+
+
+ ); +} diff --git a/package-lock.json b/package-lock.json index 07488af..dfd0ca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@fullcalendar/timegrid": "^6.1.15", "@hookform/resolvers": "^4.1.3", "@tailwindcss/vite": "^4.0.13", + "@use-gesture/react": "^10.3.1", "dayjs": "^1.11.13", "moment-timezone": "^0.5.48", "react": "^19.0.0", @@ -1905,6 +1906,24 @@ "@types/react": "^19.2.0" } }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", From 50927c60c8bca6cc06e1df89949799afa04e4c4e Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Sun, 7 Jun 2026 04:09:13 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E3=82=AB=E3=83=AC=E3=83=B3?= =?UTF-8?q?=E3=83=80=E3=83=BC=E3=82=B3=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=8D?= =?UTF-8?q?=E3=83=B3=E3=83=88=E7=A7=BB=E6=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/package.json | 7 - client/src/App.tsx | 3 - client/src/components/Calendar.tsx | 885 +++++++++------------ client/src/hooks/useCalendarScrollBlock.ts | 90 --- client/src/pages/Sandbox.tsx | 426 ---------- client/src/pages/eventId/Submission.tsx | 2 +- package-lock.json | 116 --- 7 files changed, 396 insertions(+), 1133 deletions(-) delete mode 100644 client/src/hooks/useCalendarScrollBlock.ts delete mode 100644 client/src/pages/Sandbox.tsx diff --git a/client/package.json b/client/package.json index 1b9b0ab..6ac4ae6 100644 --- a/client/package.json +++ b/client/package.json @@ -13,16 +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", - "@use-gesture/react": "^10.3.1", "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/src/App.tsx b/client/src/App.tsx index 4ff4d03..f0bb14a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,7 +4,6 @@ import HomePage from "./pages/Home.tsx"; import LandingPage from "./pages/Landing.tsx"; import NotFoundPage from "./pages/NotFound.tsx"; import ProjectPage from "./pages/Project.tsx"; -import SandboxPage from "./pages/Sandbox.tsx"; /** * Nano ID 形式の正規表現。 @@ -41,8 +40,6 @@ export default function App() { } /> } /> } /> - } /> - }> } /> diff --git a/client/src/components/Calendar.tsx b/client/src/components/Calendar.tsx index 6e8b2d6..4f16eb7 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/hooks/useCalendarScrollBlock.ts b/client/src/hooks/useCalendarScrollBlock.ts deleted file mode 100644 index f77ee34..0000000 --- a/client/src/hooks/useCalendarScrollBlock.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect } from "react"; - -/** - * 長押し時にカレンダーのスクロールをブロックする - * @param LONG_PRESS_DELAY 長押しとみなすまでの時間 (ms) - * @param MOVE_TOLERANCE これ以上動いたらスクロールとみなす (px) - */ -export default function useCalendarScrollBlock(LONG_PRESS_DELAY = 150, MOVE_TOLERANCE = 5) { - useEffect(() => { - const wrapper = document.getElementById("ih-cal-wrapper"); - if (!wrapper) return; - const scroller = wrapper.querySelector(".fc-scroller.fc-scroller-liquid-absolute") as HTMLElement | null; - if (!scroller) return; - - let pressTimer: number | null = null; - let isDragMode = false; - let startX = 0; - let startY = 0; - - const clearPressTimer = () => { - if (pressTimer !== null) { - window.clearTimeout(pressTimer); - pressTimer = null; - } - }; - - const resetDragMode = () => { - clearPressTimer(); - if (isDragMode) { - isDragMode = false; - scroller.style.overflowY = ""; - scroller.style.touchAction = ""; - } - }; - - const onTouchStart = (e: TouchEvent) => { - if (e.touches.length !== 1) return; - const t = e.touches[0]; - startX = t.clientX; - startY = t.clientY; - isDragMode = false; - clearPressTimer(); - - // 一定時間動かなければ、スクロールを無効化 - pressTimer = window.setTimeout(() => { - isDragMode = true; - scroller.style.overflowY = "hidden"; - scroller.style.touchAction = "none"; - }, LONG_PRESS_DELAY); - }; - - const onTouchMove = (e: TouchEvent) => { - if (e.touches.length !== 1) return; - const t = e.touches[0]; - const dx = Math.abs(t.clientX - startX); - const dy = Math.abs(t.clientY - startY); - - if (!isDragMode) { - // ロングプレス判定中に大きく動いたらスクロールとみなしてキャンセル - if (dx > MOVE_TOLERANCE || dy > MOVE_TOLERANCE) { - clearPressTimer(); - } - return; - } - - e.preventDefault(); // 今回は overflowY: hidden で十分だが一応 - }; - - const onTouchEnd = () => { - resetDragMode(); - }; - - const onTouchCancel = () => { - resetDragMode(); - }; - - // touchmove で preventDefault するには passive: false が必要 - scroller.addEventListener("touchstart", onTouchStart, { passive: true }); - scroller.addEventListener("touchmove", onTouchMove, { passive: false }); - scroller.addEventListener("touchend", onTouchEnd, { passive: true }); - scroller.addEventListener("touchcancel", onTouchCancel, { passive: true }); - - return () => { - scroller.removeEventListener("touchstart", onTouchStart); - scroller.removeEventListener("touchmove", onTouchMove); - scroller.removeEventListener("touchend", onTouchEnd); - scroller.removeEventListener("touchcancel", onTouchCancel); - }; - }, [LONG_PRESS_DELAY, MOVE_TOLERANCE]); -} diff --git a/client/src/pages/Sandbox.tsx b/client/src/pages/Sandbox.tsx deleted file mode 100644 index 9918edf..0000000 --- a/client/src/pages/Sandbox.tsx +++ /dev/null @@ -1,426 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useLongPressDrag } from "../hooks/useLongPressDrag"; -import { EditingMatrix, ViewingMatrix } from "../lib/CalendarMatrix"; -import dayjs from "../lib/dayjs"; - -const START_DATE = dayjs().startOf("day"); -const DAY_COUNT = 14; -const MIN_DAY_WIDTH = 56; // px per day column -const MIN_SLOT_HEIGHT = 14; // px per 15-min slot -const SLOT_START_HOUR = 9; -const SLOT_END_HOUR = 21; -const SLOT_COUNT = (SLOT_END_HOUR - SLOT_START_HOUR) * 4; -const GRID_HEIGHT = SLOT_COUNT * MIN_SLOT_HEIGHT; // 48 * 14 = 672px -const HEADER_HEIGHT = 40; // px — date header row -const TIME_COL_WIDTH = 40; // px — time label column -const INNER_WIDTH = TIME_COL_WIDTH + DAY_COUNT * MIN_DAY_WIDTH; -const OPTION_ID = "sandbox"; -const OPACITY = 0.2; -const EDGE_ZONE = 60; // px from scroll edge to trigger auto-scroll -const MAX_SCROLL_SPEED = 8; // px per animation frame - -// ダミーの参加形態 -const VIEWING_OPTIONS = [ - { id: "opt-1", label: "対面", color: "#0F82B1" }, - { id: "opt-2", label: "オンライン", color: "#10B981" }, -]; - -// ダミーのゲスト -const GUESTS: Record = { - "guest-1": "田中", - "guest-2": "鈴木", - "guest-3": "佐藤", -}; - -// ダミーの登録済みスロット -const DUMMY_VIEWING_SLOTS = [ - { guestId: "guest-1", optionId: "opt-1", from: START_DATE.hour(10), to: START_DATE.hour(12) }, - { guestId: "guest-2", optionId: "opt-1", from: START_DATE.hour(11), to: START_DATE.hour(13) }, - { guestId: "guest-3", optionId: "opt-2", from: START_DATE.hour(10), to: START_DATE.hour(11) }, - { guestId: "guest-1", optionId: "opt-2", from: START_DATE.add(1, "day").hour(10), to: START_DATE.add(1, "day").hour(12) }, - { guestId: "guest-2", optionId: "opt-1", from: START_DATE.add(1, "day").hour(10), to: START_DATE.add(1, "day").hour(11) }, - { guestId: "guest-3", optionId: "opt-1", from: START_DATE.add(2, "day").hour(14), to: START_DATE.add(2, "day").hour(16) }, - { guestId: "guest-1", optionId: "opt-1", from: START_DATE.add(2, "day").hour(14), to: START_DATE.add(2, "day").hour(15) }, -]; - -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)] - : [15, 130, 177]; -} - -type Preview = { - fromDay: number; - fromSlot: number; - toDay: number; - toSlot: number; - isDeletion: boolean; -}; - -export default function SandboxPage() { - const matrixRef = useRef(new EditingMatrix(DAY_COUNT, START_DATE)); - const [slots, setSlots] = useState(() => matrixRef.current.getSlots()); - const [preview, setPreview] = useState(null); - const gridRef = useRef(null); - const scrollRef = 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>(() => {}); - - // ViewingMatrix をダミーデータで初期化(静的なので一度だけ) - const viewingSlots = useMemo(() => { - const matrix = new ViewingMatrix(DAY_COUNT, START_DATE); - for (const s of DUMMY_VIEWING_SLOTS) { - matrix.setGuestRange(s.from, s.to, s.guestId, s.optionId); - } - return matrix.getSlots(); - }, []); - - 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) * DAY_COUNT), 0), DAY_COUNT - 1), - slot: Math.min(Math.max(Math.floor(((y - r.top) / r.height) * SLOT_COUNT), 0), SLOT_COUNT - 1), - }; - }; - - const cellToDayjs = (day: number, slot: number) => - START_DATE.add(day, "day") - .hour(SLOT_START_HOUR) - .minute(0) - .add(slot * 15, "minute"); - - // 毎レンダーで最新のクロージャに更新される。auto-scroll ループから呼び出す。 - 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 stopAutoScroll = () => { - if (autoScrollRef.current !== null) { - cancelAnimationFrame(autoScrollRef.current); - autoScrollRef.current = null; - } - }; - - 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; - } - }; - autoScrollRef.current = requestAnimationFrame(loop); - }; - - // アンマウント時に rAF をキャンセル - useEffect(() => { - return () => { - const id = autoScrollRef.current; - if (id !== null) cancelAnimationFrame(id); - autoScrollRef.current = null; - }; - }, []); - - useLongPressDrag(gridRef, { - onStart: (x, y) => { - const cell = xyToCell(x, y); - if (!cell) return; - isDeletion.current = matrixRef.current.getIsSlotExist(cellToDayjs(cell.day, cell.slot)); - dragStart.current = cell; - scrollRef.current?.classList.add("scrollbar-hidden"); - }, - onMove: (x, y) => { - 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(); - } - } - }, - 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++) { - matrixRef.current.setRange( - cellToDayjs(d, p.fromSlot), - cellToDayjs(d, p.toSlot + 1), - isDeletion.current ? null : OPTION_ID, - ); - } - setSlots(matrixRef.current.getSlots()); - previewRef.current = null; - setPreview(null); - } - dragStart.current = null; - }, - }); - - // セルインデックス → CSS パーセント位置 - const pct = (fromDay: number, fromSlot: number, daySpan: number, slotSpan: number) => ({ - left: `${(fromDay / DAY_COUNT) * 100}%`, - top: `${(fromSlot / SLOT_COUNT) * 100}%`, - width: `${(daySpan / DAY_COUNT) * 100}%`, - height: `${(slotSpan / SLOT_COUNT) * 100}%`, - }); - - // Dayjs → グリッド内スロットインデックス - const toSlotIdx = (dt: ReturnType) => - (dt.hour() - SLOT_START_HOUR) * 4 + dt.minute() / 15; - - return ( -
-

カレンダー サンドボックス

- - {/* 凡例 */} -
- {VIEWING_OPTIONS.map((opt) => ( - - - {opt.label} - - ))} - - 参加者: {Object.values(GUESTS).join(" / ")} - -
- - {/* - 単一スクロールコンテナ(縦横両対応)。 - 内部の日付ヘッダーは sticky top-0、時刻ラベル列は sticky left-0 で固定。 - */} -
-
- - {/* - コーナーオーバーレイ: 縦横どちらにスクロールしても左上に固定。 - inner div の直接の子にすることで z-20 がスタッキングコンテキストを - 跨がず日付ヘッダー(z-10)・時刻ラベル(z-10) 両方を覆う。 - marginBottom で後続の日付ヘッダーのレイアウト位置を変えない。 - */} -
- - {/* 日付ヘッダー行(sticky: 縦スクロール時に上部固定) */} -
- {/* コーナーオーバーレイに隠れる部分のレイアウトスペーサー */} -
-
- {Array.from({ length: DAY_COUNT }, (_, i) => { - const d = START_DATE.add(i, "day"); - return ( -
- {d.format("M/D")} - {d.format("(ddd)")} -
- ); - })} -
-
- - {/* コンテンツ行: 時刻ラベル + グリッド本体(mt-2 で 9:00 ラベルの上半分を確保) */} -
- - {/* 時刻ラベル列(sticky: 横スクロール時に左端固定) */} -
-
- {Array.from({ length: (SLOT_END_HOUR - SLOT_START_HOUR) * 2 + 1 }, (_, i) => { - const hour = SLOT_START_HOUR + Math.floor(i / 2); - const min = i % 2 === 0 ? "00" : "30"; - return ( -
- {`${hour}:${min}`} -
- ); - })} -
-
- - {/* グリッド本体 */} -
- {/* 枠線オーバーレイ: ring/border を使わずコンテンツエリアへの影響を防ぐ */} -
- - {/* 閲覧スロット(他人のイベント) */} - {viewingSlots.map((slot) => { - const dayIdx = slot.from.startOf("day").diff(START_DATE, "day"); - const fromSlot = toSlotIdx(slot.from); - const toSlot = toSlotIdx(slot.to); - if (dayIdx < 0 || dayIdx >= DAY_COUNT || 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]); - } - } - - // VIEWING_OPTIONS の順番でブレークダウンを作成 - const breakdown = VIEWING_OPTIONS.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 }; - }); - - // 背景スタイル: 単色 or グラデーション - let background: string; - if (breakdown.length === 1) { - const [r, g, b] = hexToRgb(breakdown[0].color); - background = `rgba(${r},${g},${b},${breakdown[0].opacity})`; - } else { - 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}) ${j * w}%, rgba(${r},${g},${b},${bd.opacity}) ${(j + 1) * w}%`; - }) - .join(", "); - background = `linear-gradient(90deg, ${stops})`; - } - - return ( -
- {/* 参加形態ごとにバッジをその色帯の中央に配置 */} -
- {breakdown.map((bd, j) => { - const position = ((j + 0.5) / breakdown.length) * 100; - return ( -
- - {bd.guestIds.length} - -
- ); - })} -
-
- ); - })} - - {/* 編集中スロット(自分のイベント) */} - {slots.map((slot) => { - const dayIdx = slot.from.startOf("day").diff(START_DATE, "day"); - const fromSlot = toSlotIdx(slot.from); - const toSlot = toSlotIdx(slot.to); - return ( -
- ); - })} - - {/* ドラッグ中プレビュー */} - {preview && ( -
- )} -
-
-
-
-
- ); -} diff --git a/client/src/pages/eventId/Submission.tsx b/client/src/pages/eventId/Submission.tsx index 33ed103..a249a21 100644 --- a/client/src/pages/eventId/Submission.tsx +++ b/client/src/pages/eventId/Submission.tsx @@ -297,7 +297,7 @@ export default function SubmissionPage() {
) : (
-
+
{/* プロジェクト情報 */}
diff --git a/package-lock.json b/package-lock.json index dfd0ca4..835c8ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,16 +20,9 @@ "version": "0.0.0", "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", - "@use-gesture/react": "^10.3.1", "dayjs": "^1.11.13", - "moment-timezone": "^0.5.48", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", @@ -999,66 +992,6 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@fullcalendar/core": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", - "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", - "license": "MIT", - "dependencies": { - "preact": "~10.12.1" - } - }, - "node_modules/@fullcalendar/daygrid": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz", - "integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==", - "license": "MIT", - "peerDependencies": { - "@fullcalendar/core": "~6.1.20" - } - }, - "node_modules/@fullcalendar/interaction": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz", - "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==", - "license": "MIT", - "peerDependencies": { - "@fullcalendar/core": "~6.1.20" - } - }, - "node_modules/@fullcalendar/moment-timezone": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/moment-timezone/-/moment-timezone-6.1.20.tgz", - "integrity": "sha512-fGk3bQU4hf0rgw3Zd/PH6Ok0Db+s9/nsuALj3IG8GYFqInwLsHZI0Qc+ljN8jv9LrLS5sOBBOZHWDg2ncx1inw==", - "license": "MIT", - "peerDependencies": { - "@fullcalendar/core": "~6.1.20", - "moment-timezone": "^0.5.40" - } - }, - "node_modules/@fullcalendar/react": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.20.tgz", - "integrity": "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==", - "license": "MIT", - "peerDependencies": { - "@fullcalendar/core": "~6.1.20", - "react": "^16.7.0 || ^17 || ^18 || ^19", - "react-dom": "^16.7.0 || ^17 || ^18 || ^19" - } - }, - "node_modules/@fullcalendar/timegrid": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz", - "integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==", - "license": "MIT", - "dependencies": { - "@fullcalendar/daygrid": "~6.1.20" - }, - "peerDependencies": { - "@fullcalendar/core": "~6.1.20" - } - }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -1906,24 +1839,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@use-gesture/core": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", - "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", - "license": "MIT" - }, - "node_modules/@use-gesture/react": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", - "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", - "license": "MIT", - "dependencies": { - "@use-gesture/core": "10.3.1" - }, - "peerDependencies": { - "react": ">= 16.8.0" - } - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2712,27 +2627,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/moment-timezone": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", - "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", - "license": "MIT", - "dependencies": { - "moment": "^2.29.4" - }, - "engines": { - "node": "*" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2894,16 +2788,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/preact": { - "version": "10.12.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", - "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prisma": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", From 7addb0d22b9fb629d9bdc097b797ea004a410294 Mon Sep 17 00:00:00 2001 From: nakaterm <104970808+nakaterm@users.noreply.github.com> Date: Sun, 7 Jun 2026 04:26:25 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E7=A7=BB=E6=A4=8D=E3=81=AB?= =?UTF-8?q?=E4=BC=B4=E3=81=86=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/Calendar.tsx | 4 +- client/src/index.css | 59 ------------------------------ 2 files changed, 2 insertions(+), 61 deletions(-) diff --git a/client/src/components/Calendar.tsx b/client/src/components/Calendar.tsx index 4f16eb7..6bae9b2 100644 --- a/client/src/components/Calendar.tsx +++ b/client/src/components/Calendar.tsx @@ -275,8 +275,8 @@ export const Calendar = ({ const halfHourCount = Math.floor((slotEndMinutes - slotStartMinutes) / 30) + 1; return ( -
-
+
+
{/* コーナーオーバーレイ: 縦横どちらにスクロールしても左上に固定 */}