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

@ -19,6 +19,24 @@ async function setupOwner(page: import('@playwright/test').Page) {
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
}
function isoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function addDaysISO(iso: string, n: number): string {
const [y, m, d] = iso.split('-').map(Number);
const dt = new Date(y, m - 1, d);
dt.setDate(dt.getDate() + n);
return isoDate(dt);
}
function urlDate(url: string): string | null {
return new URL(url).searchParams.get('date');
}
test.describe('calendar & milestones', () => {
test('milestone progress reflects related todo completion; calendar aggregates', async ({
page,
@ -91,9 +109,104 @@ test.describe('calendar & milestones', () => {
);
expect(evRes.ok()).toBeTruthy();
// カレンダー画面にイベント+マイルストーン+ToDoが集約表示される
// カレンダー画面(月表示)にイベント+マイルストーン+ToDoがグリッド表示される
await page.goto(`/projects/${projectId}/calendar`);
await expect(page.getByTestId('calendar-view-month')).toBeVisible();
await expect(page.getByText(/マイルストーン:/)).toBeVisible();
await expect(page.getByText(/ToDo:/)).toBeVisible();
});
test('switches between month/week/day views and opens date detail', async ({
page,
}) => {
const projectId = await setupOwner(page);
const todayKey = isoDate(new Date());
const eventTitle = unique('Evt');
// 今日のイベントを作成(デフォルトの月表示で今日のセルに表示される)
const evRes = await page.request.post(
`/api/projects/${projectId}/calendar/events`,
{
data: {
title: eventTitle,
type: 'custom',
startAt: `${todayKey}T10:00:00`,
},
}
);
expect(evRes.ok()).toBeTruthy();
await page.goto(`/projects/${projectId}/calendar`);
// 月表示: 今日のセルにイベントチップ
await expect(page.getByTestId('calendar-view-month')).toBeVisible();
await expect(page.getByTestId(`calendar-day-${todayKey}`)).toBeVisible();
await expect(page.getByText(eventTitle)).toBeVisible();
// 週表示へ切替
await page.getByTestId('calendar-view-week').click();
await expect(
page.getByTestId(`calendar-week-cell-${todayKey}`)
).toBeVisible();
await expect(page.getByText(eventTitle)).toBeVisible();
// 日表示(時間グリッド)へ切替: 10時行にイベント
await page.getByTestId('calendar-view-day').click();
await expect(page.getByTestId('calendar-hour-10')).toBeVisible();
await expect(page.getByText(eventTitle)).toBeVisible();
// 月表示へ戻す
await page.getByTestId('calendar-view-month').click();
await expect(page.getByTestId('calendar-view-month')).toBeVisible();
// 日付をクリックして詳細ダイアログを開く
await page.getByLabel(`${todayKey} の詳細を開く`).click();
const dialog = page.getByTestId('calendar-detail-dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText(eventTitle)).toBeVisible();
// ダイアログを閉じる
await page.getByTestId('calendar-detail-close').click();
await expect(dialog).toBeHidden();
});
test('prev/next/today navigation moves the anchor by the view unit', async ({
page,
}) => {
const projectId = await setupOwner(page);
await page.goto(`/projects/${projectId}/calendar`);
const title = page.getByTestId('calendar-title');
await expect(title).toContainText('月');
// 月表示: next でタイトル変化、prev で元に戻る
const before = (await title.textContent()) ?? '';
await page.getByTestId('calendar-next').click();
await expect(title).not.toHaveText(before);
await page.getByTestId('calendar-prev').click();
await expect(title).toHaveText(before);
// 今日ボタンで今日に戻る
await page.getByTestId('calendar-next').click();
await page.getByTestId('calendar-today').click();
await expect(title).toHaveText(before);
// 週表示: next で7日進む
await page.getByTestId('calendar-view-week').click();
const weekStart = urlDate(page.url());
expect(weekStart).not.toBeNull();
await page.getByTestId('calendar-next').click();
expect(urlDate(page.url())).toBe(addDaysISO(weekStart!, 7));
await page.getByTestId('calendar-prev').click();
expect(urlDate(page.url())).toBe(weekStart);
// 日表示: next で1日進む
await page.getByTestId('calendar-view-day').click();
const dayStart = urlDate(page.url());
expect(dayStart).not.toBeNull();
await page.getByTestId('calendar-next').click();
expect(urlDate(page.url())).toBe(addDaysISO(dayStart!, 1));
await page.getByTestId('calendar-prev').click();
expect(urlDate(page.url())).toBe(dayStart);
});
});