Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b514240
fix: イベントの日付・時刻の形式不正を 400 で弾く
nakaterm Oct 22, 2026
3a40fa1
test: レスポンスの形を固定するテストを足す
nakaterm Oct 22, 2026
defa08e
fix: 回答の更新のレスポンスに browserId を含めない
nakaterm Oct 22, 2026
14e9901
test: マイグレーションによるデータの変換を確かめる基盤を足す
nakaterm Oct 22, 2026
87ffa07
feat(db): Project にタイムゾーンと作成日時を追加
nakaterm Oct 22, 2026
194940c
refactor(db): 時間帯の時刻を "HH:mm" で保存する
nakaterm Oct 22, 2026
2d2a257
refactor: API の時間帯を "HH:mm" で受け取って返す
nakaterm Oct 22, 2026
87a8d44
fix: イベントの更新を PATCH にし、省略したフィールドを変えない
nakaterm Oct 22, 2026
ec79425
fix: イベントの削除は 204 を本文なしで返す
nakaterm Oct 22, 2026
73bbed2
refactor: イベントの日程を "YYYY-MM-DD" で保存し、そのまま受け取って返す
nakaterm Oct 22, 2026
8017cee
refactor(db): テーブル名を小文字の単数形・camelCase にする
nakaterm Oct 22, 2026
19e42c0
refactor: 列名をテーブル名に合わせ、Slot の API の名前をそろえる
nakaterm Oct 22, 2026
3050384
refactor: Host と Guest を participant にまとめる
nakaterm Oct 22, 2026
a89941a
refactor: server の内部の名前を project から event にする
nakaterm Oct 22, 2026
efdd843
refactor: API と client の名前を project から event にそろえる
nakaterm Oct 22, 2026
a85826a
docs: CLAUDE.md をスキーマ再設計後のコードに合わせる
nakaterm Oct 22, 2026
ad8dcff
fix: npm run dev:functions を動くようにする
nakaterm Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,23 @@ import type { AppType } from "../../../../server/src/main";
const client = hc<AppType>(API_ENDPOINT);
```

REST fetch ではなく `client.projects[":projectId"].$get(...)` 形式で呼び出す。
REST fetch ではなく `client.events[":eventId"].$get(...)` 形式で呼び出す。

### データモデル(D1)

`server/src/db/schema.ts`。テーブル名は小文字の単数形・camelCase。

- `event`: 日程調整のイベント。`startDate` / `endDate` は `"YYYY-MM-DD"`(`endDate` を含む)、`timezone` は IANA のタイムゾーン名(現在は全件 `Asia/Tokyo`)。
- `allowedRange`: 入力できる時間帯(現在はイベントにつき 1 つ)。`startTime` / `endTime` は `"HH:mm"`。終わりは `"24:00"` もありうる。
- `option`: 参加形態。
- `participant`: 参加者。`role` でホストとゲストを区別する。`name` が null なら未回答(イベントを作っただけのホスト)。ホストが回答しても 1 行のまま。
- `slot`: 参加者が参加できる時間帯。`startAt` / `endAt` は UTC の ISO 8601 で、日をまたがない。

日付と時間帯は、イベントの `timezone` の壁時計の値で持つ。Slot だけは実際の時刻なので UTC で持つ。API も同じ形式で受け取って返す。

API のレスポンスでは、参加者を `hosts`(ホスト)、`guests` と `meAsGuest`(回答した参加者)に分けて返し、Slot の参加者 ID は `guestId` として返す。

マイグレーションは `server/migrations/`。データの変換を伴うものは `drizzle-kit generate --custom` で作って SQL を手で書き、`server/test/migrations.test.ts` に移行のテストを足す。D1 は外部キーを常に強制し、`DROP TABLE` の暗黙の DELETE で `ON DELETE CASCADE` が発火するので、他のテーブルから参照されるテーブルは作り直さない(`RENAME` や `ADD COLUMN` で変える)。

### 認証(browserId)

Expand All @@ -57,12 +73,13 @@ REST fetch ではなく `client.projects[":projectId"].$get(...)` 形式で呼

`client/src/lib/CalendarMatrix.ts` に核となるデータ構造がある。カレンダーの選択状態は 2D 行列で管理される(行=日、列=15分単位のスロット):

- **`EditingMatrix`**: 自分の入力中スロット。セル値は `participationOptionId`(文字列)。
- **`EditingMatrix`**: 自分の入力中スロット。セル値は参加形態の id(文字列)。
- **`ViewingMatrix`**: 全ゲストの既存スロット。セル値は `{ [guestId]: optionId }` のレコード。
- **`HighlightMatrix`**: 「最多人数」や特定のゲストの強調表示に使う、条件を満たすセル。

どちらも `getSlots()` で連続するセルをまとめてイベント単位に変換(run-length encoding 的な処理)して返す。
いずれも `getSlots()` で連続するセルをまとめて区間に変換(run-length encoding 的な処理)して返す。

`Calendar.tsx` はこのマトリックスを `useRef` で保持し、スロット変更時に `editingSlots → matrix → CalendarEvent[]` の順で再計算して FullCalendar に渡す。
`Calendar.tsx` は `EditingMatrix` を `useRef` で保持し、`editingSlots` が変わると行列を作り直して区間を再計算する。`ViewingMatrix` は `viewingSlots` から `useMemo` で作る。どちらの区間も、グリッドの上に絶対配置で直接描画する。区間の終わりは 24:00(翌日の 0:00)になりうるので、位置は開始からの経過時間で求める。

### dayjs のインポート制限

Expand All @@ -75,10 +92,10 @@ import dayjs from "dayjs";
import dayjs from "../lib/dayjs";
```

### 参加形態(ParticipationOption)の流れ
### 参加形態(option)の流れ

- 作成時: フロントエンドで `crypto.randomUUID()` を生成して `id` を確定。サーバーは受け取った id をそのまま使用。
- デフォルト: 参加形態が0件の場合は `common/colors.ts` の `DEFAULT_PARTICIPATION_OPTION` を使用してデフォルトを自動作成(label: "参加", color: "#0F82B1")。
- デフォルト: イベントの作成フォームの初期値に、`common/colors.ts` の `DEFAULT_PARTICIPATION_OPTION`(label: "参加", color: "#0F82B1")を 1 つ入れる。API は参加形態を最低 1 つ要求する。
- 削除制限: Slot が紐づいている参加形態は削除不可(サーバー側で検証)。

### Cloudflare Pages Functions
Expand Down
24 changes: 12 additions & 12 deletions client/functions/[[path]].ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ interface Env {
API_ENDPOINT: string;
}

interface ProjectData {
interface EventData {
id: string;
name: string;
startDate: string;
endDate: string;
allowedRanges: Array<{
id: string;
projectId: string;
eventId: string;
startTime: string;
endTime: string;
}>;
Expand Down Expand Up @@ -71,16 +71,16 @@ function formatDate(dateString: string): string {
.replace(/-/g, "/");
}

// プロジェクト情報を取得
async function fetchProjectData(eventId: string, apiEndpoint: string): Promise<ProjectData | null> {
// イベント情報を取得
async function fetchEventData(eventId: string, apiEndpoint: string): Promise<EventData | null> {
try {
const response = await fetch(`${apiEndpoint}/projects/${eventId}`);
const response = await fetch(`${apiEndpoint}/events/${eventId}`);
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error("Failed to fetch project data:", error);
console.error("Failed to fetch event data:", error);
return null;
}
}
Expand Down Expand Up @@ -140,21 +140,21 @@ export async function onRequest(context: EventContext<Env, any, any>): Promise<R
return response;
}

const projectData = await fetchProjectData(eventId, env.API_ENDPOINT);
const eventData = await fetchEventData(eventId, env.API_ENDPOINT);

const defaultOgImageUrl = `${url.origin}/og-image.jpg`;

if (!projectData) {
// プロジェクトが見つからない場合はデフォルト画像を設定
if (!eventData) {
// イベントが見つからない場合はデフォルト画像を設定
return new HTMLRewriter()
.on('meta[property="og:image"]', new OGImageRewriter(defaultOgImageUrl))
.transform(response);
}

const ogTitle = `${projectData.name} - イツヒマ`;
const ogTitle = `${eventData.name} - イツヒマ`;

const startDate = formatDate(projectData.startDate);
const endDate = formatDate(projectData.endDate);
const startDate = formatDate(eventData.startDate);
const endDate = formatDate(eventData.endDate);
const dateRange = startDate === endDate ? startDate : `${startDate} 〜 ${endDate}`;
const ogDescription = `日程範囲: ${dateRange}`;

Expand Down
22 changes: 11 additions & 11 deletions client/functions/og/[eventId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,22 @@ interface Env {
API_ENDPOINT: string;
}

interface ProjectData {
interface EventData {
id: string;
name: string;
startDate: string;
endDate: string;
}

async function fetchProjectData(eventId: string, apiEndpoint: string): Promise<ProjectData | null> {
async function fetchEventData(eventId: string, apiEndpoint: string): Promise<EventData | null> {
try {
const response = await fetch(`${apiEndpoint}/projects/${eventId}`);
const response = await fetch(`${apiEndpoint}/events/${eventId}`);
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error("Failed to fetch project data:", error);
console.error("Failed to fetch event data:", error);
return null;
}
}
Expand Down Expand Up @@ -75,10 +75,10 @@ export const onRequestGet: PagesFunction<Env, "eventId"> = async (context) => {
return new Response("Invalid event ID", { status: 400 });
}

const projectData = await fetchProjectData(eventId, env.API_ENDPOINT);
const eventData = await fetchEventData(eventId, env.API_ENDPOINT);

if (!projectData) {
// プロジェクトがない場合はデフォルト画像にリダイレクト
if (!eventData) {
// イベントがない場合はデフォルト画像にリダイレクト
const url = new URL(context.request.url);
return Response.redirect(`${url.origin}/og-image.jpg`, 302);
}
Expand All @@ -87,10 +87,10 @@ export const onRequestGet: PagesFunction<Env, "eventId"> = async (context) => {
"https://unpkg.com/@fontsource/m-plus-1p@5.2.8/files/m-plus-1p-japanese-700-normal.woff",
).then((res) => res.arrayBuffer());

const projectName = truncateByWidth(projectData.name, 72);
const eventName = truncateByWidth(eventData.name, 72);

const startDate = formatDate(projectData.startDate);
const endDate = formatDate(projectData.endDate);
const startDate = formatDate(eventData.startDate);
const endDate = formatDate(eventData.endDate);
const dateRange = startDate === endDate ? startDate : `${startDate} 〜 ${endDate}`;

const response = new ImageResponse(
Expand Down Expand Up @@ -134,7 +134,7 @@ export const onRequestGet: PagesFunction<Env, "eventId"> = async (context) => {
wordBreak: "break-word",
}}
>
{projectName}
{eventName}
</span>
<span
style={{
Expand Down
5 changes: 3 additions & 2 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250610.0",
"@cloudflare/workers-types": "^5.20261022.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"daisyui": "^5.0.3",
"hono": "^4.9.6",
"typescript": "~5.7.2",
"vite": "^6.3.5"
"vite": "^6.3.5",
"wrangler": "^4.151.3"
}
}
6 changes: 3 additions & 3 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { BrowserRouter, Navigate, Outlet, Route, Routes, useParams } from "react-router";
import EventFormPage from "./pages/EventForm.tsx";
import SubmissionPage from "./pages/eventId/Submission.tsx";
import HomePage from "./pages/Home.tsx";
import LandingPage from "./pages/Landing.tsx";
import NotFoundPage from "./pages/NotFound.tsx";
import ProjectPage from "./pages/Project.tsx";

/**
* Nano ID 形式の正規表現。
Expand Down Expand Up @@ -39,11 +39,11 @@ export default function App() {
<Routes>
<Route index element={<LandingPage />} />
<Route path="home" element={<HomePage />} />
<Route path="new" element={<ProjectPage />} />
<Route path="new" element={<EventFormPage />} />
<Route path="e">
<Route path=":eventId" element={<Outlet />}>
<Route index element={<SubmissionPage />} />
<Route path="edit" element={<ProjectPage />} />
<Route path="edit" element={<EventFormPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Route>
Expand Down
18 changes: 9 additions & 9 deletions client/src/components/AddToCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { generateIcs } from "../lib/generateIcs";
import type { Slot } from "../types";

type Props = {
projectName: string;
projectId: string;
projectDescription?: string | null;
eventName: string;
eventId: string;
eventDescription?: string | null;
slots: Pick<Slot, "from" | "to" | "participationOptionId">[];
participationOptionIdToLabel: Record<string, string>;
// 参加形態がデフォルト値のみ(=実質未設定)かどうかの判定に使う
Expand All @@ -20,17 +20,17 @@ type Props = {
* カレンダーの縦幅を圧迫しないよう、タイトル行に並べて置く想定(モバイルではアイコンのみ)。
*/
export function AddToCalendar({
projectName,
projectId,
projectDescription,
eventName,
eventId,
eventDescription,
slots,
participationOptionIdToLabel,
participationOptionCount,
}: Props) {
const handleClick = () => {
const eventUrl = `${window.location.origin}/e/${projectId}`;
const eventUrl = `${window.location.origin}/e/${eventId}`;
const icsContent = generateIcs(
projectName,
eventName,
slots.map((slot) => {
const label = participationOptionIdToLabel[slot.participationOptionId] ?? "";
// 参加形態がデフォルト値のみで他に選択肢がない場合、タイトルには含めない
Expand All @@ -42,7 +42,7 @@ export function AddToCalendar({
};
}),
eventUrl,
projectDescription ?? undefined,
eventDescription ?? undefined,
);

const blob = new Blob([icsContent], { type: "text/calendar;charset=utf-8" });
Expand Down
29 changes: 18 additions & 11 deletions client/src/components/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { EditingMatrix, ViewingMatrix } from "../lib/CalendarMatrix";
import type { Dayjs } from "../lib/dayjs";
import type { EditingSlot } from "../pages/eventId/Submission";

/** 時刻は "HH:mm"。終わりは "24:00" もありうる */
type AllowedRange = {
startTime: Dayjs;
endTime: Dayjs;
startTime: string;
endTime: string;
};

type ViewingSlot = {
Expand Down Expand Up @@ -78,6 +79,12 @@ function hexToRgb(hex: string): [number, number, number] {
: PRIMARY_RGB;
}

/** "HH:mm" を 0 時からの経過分にする */
function toMinutes(time: string): number {
const [hour, minute] = time.split(":").map(Number);
return hour * 60 + minute;
}

export const Calendar = ({
startDate,
endDate,
Expand All @@ -94,13 +101,11 @@ export const Calendar = ({
}: Props) => {
const countDays = endDate.startOf("day").diff(startDate.startOf("day"), "day") + 1;

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 allowedRange = allowedRanges[0] ?? { startTime: "00:00", endTime: "24:00" };
const slotStartMinutes = toMinutes(allowedRange.startTime);
const slotEndMinutes = toMinutes(allowedRange.endTime);
const slotCount = (slotEndMinutes - slotStartMinutes) / 15;

const gridHeight = slotCount * MIN_SLOT_HEIGHT;
const innerWidth = TIME_COL_WIDTH + countDays * MIN_DAY_WIDTH;
Expand Down Expand Up @@ -411,7 +416,8 @@ export const Calendar = ({
{computedViewingSlots.map((slot) => {
const dayIdx = slot.from.startOf("day").diff(startDate.startOf("day"), "day");
const fromSlot = toSlotIdx(slot.from);
const toSlot = toSlotIdx(slot.to);
// to は 24:00(翌日 0:00)になりうるので、from からの経過時間で求める
const toSlot = fromSlot + slot.to.diff(slot.from, "minute") / 15;
if (dayIdx < 0 || dayIdx >= countDays || fromSlot < 0 || toSlot <= fromSlot) return null;

const optionGroups = new Map<string, string[]>();
Expand Down Expand Up @@ -509,7 +515,8 @@ export const Calendar = ({
{slots.map((slot) => {
const dayIdx = slot.from.startOf("day").diff(startDate.startOf("day"), "day");
const fromSlot = toSlotIdx(slot.from);
const toSlot = toSlotIdx(slot.to);
// to は 24:00(翌日 0:00)になりうるので、from からの経過時間で求める
const toSlot = fromSlot + slot.to.diff(slot.from, "minute") / 15;
if (dayIdx < 0 || dayIdx >= countDays) return null;

const option = participationOptions.find((o) => o.id === slot.optionId);
Expand Down
8 changes: 4 additions & 4 deletions client/src/lib/generateIcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,22 @@ function toUtcDateArray(date: Dayjs): DateArray {
}

export function generateIcs(
projectName: string,
eventName: string,
slots: SlotForIcs[],
eventUrl?: string,
projectDescription?: string,
eventDescription?: string,
): string {
const description = [
"イツヒマで提出した参加候補日程です。",
eventUrl ? `イベントページ: ${eventUrl}` : null,
projectDescription ? `\n${projectDescription}` : null,
eventDescription ? `\n${eventDescription}` : null,
]
.filter(Boolean)
.join("\n");

const { error, value } = createEvents(
slots.map((slot) => ({
title: `【候補】${projectName}${slot.label ? `(${slot.label})` : ""} - イツヒマ`,
title: `【候補】${eventName}${slot.label ? `(${slot.label})` : ""} - イツヒマ`,
description,
start: toUtcDateArray(slot.from),
startInputType: "utc",
Expand Down
Loading
Loading