feat(calendar): replace list view with month/week/day grid + date detail dialog

カレンダー画面を一覧表示から実際のカレンダーグリッドへ変更。

- lib/calendar/grid.ts: 月グリッド/週/時間グリッド/取得範囲(rangeForView)の純粋な日付ヘルパー (+22 unit tests)
- components/calendar/: CalendarView(月/週/日 切替・prev/next/今日)・MonthView(truncateチップ)・WeekView・DayView(時間グリッド+終日)・EventDetailDialog(詳細・フォーカス管理)・sourceColors
- page.tsx: view/date searchParamsから範囲を計算しServer Componentで取得、CalendarViewへ渡す
- e2e: 表示切替・詳細ダイアログ・ナビステップ(週=7日/日=1日)を追加
This commit is contained in:
Ken Yasue
2026-06-25 08:46:58 +02:00
parent 9fce9e9215
commit 41d65f58bb
11 changed files with 1127 additions and 50 deletions

View File

@ -0,0 +1,185 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { CalendarEventView } from '@/services/ScheduleService';
import {
getMonthGrid,
getWeekDays,
parseISODate,
stepAnchor,
toISODate,
eventsOnDay,
type CalendarViewMode,
} from '@/lib/calendar/grid';
import { WEEKDAY_LABELS } from '@/components/calendar/sourceColors';
import { MonthView } from '@/components/calendar/MonthView';
import { WeekView } from '@/components/calendar/WeekView';
import { DayView } from '@/components/calendar/DayView';
import { EventDetailDialog } from '@/components/calendar/EventDetailDialog';
const VIEW_LABELS: { mode: CalendarViewMode; label: string }[] = [
{ mode: 'month', label: '月' },
{ mode: 'week', label: '週' },
{ mode: 'day', label: '日' },
];
/**
* カレンダーUIのクライアントコンテナ。
* 表示モード切替・前/次/今日ナビ・詳細ダイアログを管理し、
* データ取得はURL searchParams経由でServer Componentに委ねる。
*/
export function CalendarView({
projectId,
events,
view,
anchorDate,
todayKey,
}: {
projectId: number;
events: CalendarEventView[];
view: CalendarViewMode;
anchorDate: string;
todayKey: string;
}) {
const router = useRouter();
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(null);
const anchor = parseISODate(anchorDate);
function navigate(nextView: CalendarViewMode, nextAnchor: Date) {
const params = new URLSearchParams({
view: nextView,
date: toISODate(nextAnchor),
});
router.push(`/projects/${projectId}/calendar?${params.toString()}`);
}
function changeView(nextView: CalendarViewMode) {
navigate(nextView, anchor);
}
function goPrev() {
navigate(view, stepAnchor(view, anchor, -1));
}
function goNext() {
navigate(view, stepAnchor(view, anchor, 1));
}
function goToday() {
navigate(view, new Date());
}
function openDetail(dateKey: string) {
setSelectedDateKey(dateKey);
}
const title = buildTitle(view, anchor);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2
className="text-xl font-bold text-gray-800"
data-testid="calendar-title"
>
{title}
</h2>
<div className="flex items-center gap-2">
<div className="flex overflow-hidden rounded border">
{VIEW_LABELS.map((v) => (
<button
key={v.mode}
type="button"
onClick={() => changeView(v.mode)}
className={`px-3 py-1 text-sm ${
view === v.mode
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100'
}`}
data-testid={`calendar-view-${v.mode}`}
>
{v.label}
</button>
))}
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={goPrev}
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100"
aria-label="前へ"
data-testid="calendar-prev"
>
</button>
<button
type="button"
onClick={goToday}
className="rounded border bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
data-testid="calendar-today"
>
</button>
<button
type="button"
onClick={goNext}
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100"
aria-label="次へ"
data-testid="calendar-next"
>
</button>
</div>
</div>
</div>
{view === 'month' && (
<MonthView
events={events}
weeks={getMonthGrid(anchor.getFullYear(), anchor.getMonth())}
anchorMonth={anchor.getMonth()}
todayKey={todayKey}
onSelectDate={openDetail}
/>
)}
{view === 'week' && (
<WeekView
events={events}
days={getWeekDays(anchor)}
todayKey={todayKey}
onSelectDate={openDetail}
/>
)}
{view === 'day' && (
<DayView
day={anchor}
events={events}
todayKey={todayKey}
onSelectDate={openDetail}
/>
)}
{selectedDateKey && (
<EventDetailDialog
date={parseISODate(selectedDateKey)}
events={eventsOnDay(events, selectedDateKey)}
onClose={() => setSelectedDateKey(null)}
/>
)}
</div>
);
}
function buildTitle(view: CalendarViewMode, anchor: Date): string {
if (view === 'month') {
return `${anchor.getFullYear()}${anchor.getMonth() + 1}`;
}
if (view === 'week') {
const days = getWeekDays(anchor);
const s = days[0];
const e = days[6];
return `${s.getFullYear()}${s.getMonth() + 1}${s.getDate()}日 〜 ${e.getMonth() + 1}${e.getDate()}`;
}
return `${anchor.getFullYear()}${anchor.getMonth() + 1}${anchor.getDate()}日(${WEEKDAY_LABELS[anchor.getDay()]})`;
}