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:
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
175
tests/unit/lib/calendar/grid.test.ts
Normal file
175
tests/unit/lib/calendar/grid.test.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
toISODate,
|
||||
parseISODate,
|
||||
addDays,
|
||||
addMonths,
|
||||
startOfWeek,
|
||||
getMonthGrid,
|
||||
getWeekDays,
|
||||
getDayHours,
|
||||
eventDateKey,
|
||||
eventHour,
|
||||
formatTime,
|
||||
eventsOnDay,
|
||||
rangeForView,
|
||||
stepAnchor,
|
||||
} from '@/lib/calendar/grid';
|
||||
|
||||
describe('calendar/grid', () => {
|
||||
describe('toISODate / parseISODate', () => {
|
||||
it('toISODate formats a local date as YYYY-MM-DD', () => {
|
||||
expect(toISODate(new Date(2026, 5, 15))).toBe('2026-06-15');
|
||||
expect(toISODate(new Date(2026, 0, 9))).toBe('2026-01-09');
|
||||
});
|
||||
|
||||
it('parseISODate round-trips through toISODate', () => {
|
||||
const d = parseISODate('2026-06-15');
|
||||
expect(toISODate(d)).toBe('2026-06-15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('startOfWeek', () => {
|
||||
it('returns the Sunday of the given date week', () => {
|
||||
// 2026-06-17 is a Wednesday
|
||||
expect(toISODate(startOfWeek(new Date(2026, 5, 17)))).toBe('2026-06-14');
|
||||
});
|
||||
|
||||
it('returns the same date when already on Sunday', () => {
|
||||
expect(toISODate(startOfWeek(new Date(2026, 5, 14)))).toBe('2026-06-14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMonthGrid', () => {
|
||||
it('produces full Sun-Sat weeks covering the month', () => {
|
||||
// June 2026: 1st is Monday, 30th is Tuesday
|
||||
const weeks = getMonthGrid(2026, 5);
|
||||
for (const week of weeks) {
|
||||
expect(week).toHaveLength(7);
|
||||
expect(week[0].getDay()).toBe(0); // Sunday
|
||||
expect(week[6].getDay()).toBe(6); // Saturday
|
||||
}
|
||||
// First cell is the Sunday on/before June 1 (May 31)
|
||||
expect(toISODate(weeks[0][0])).toBe('2026-05-31');
|
||||
// Last cell is the Saturday on/after June 30 (July 4)
|
||||
expect(toISODate(weeks[weeks.length - 1][6])).toBe('2026-07-04');
|
||||
});
|
||||
|
||||
it('starts on the 1st when the month begins on Sunday', () => {
|
||||
// February 2026: 1st is a Sunday
|
||||
const weeks = getMonthGrid(2026, 1);
|
||||
expect(toISODate(weeks[0][0])).toBe('2026-02-01');
|
||||
});
|
||||
|
||||
it('produces 4 to 6 weeks', () => {
|
||||
const weeks = getMonthGrid(2026, 5);
|
||||
expect(weeks.length).toBeGreaterThanOrEqual(4);
|
||||
expect(weeks.length).toBeLessThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWeekDays / getDayHours', () => {
|
||||
it('returns 7 consecutive days Sunday-Saturday', () => {
|
||||
const days = getWeekDays(new Date(2026, 5, 17));
|
||||
expect(days).toHaveLength(7);
|
||||
expect(toISODate(days[0])).toBe('2026-06-14');
|
||||
expect(toISODate(days[6])).toBe('2026-06-20');
|
||||
});
|
||||
|
||||
it('returns hours 0..23', () => {
|
||||
expect(getDayHours()).toEqual(Array.from({ length: 24 }, (_, i) => i));
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDays / addMonths', () => {
|
||||
it('addDays moves the date', () => {
|
||||
expect(toISODate(addDays(new Date(2026, 5, 15), 10))).toBe('2026-06-25');
|
||||
expect(toISODate(addDays(new Date(2026, 5, 15), -15))).toBe('2026-05-31');
|
||||
});
|
||||
|
||||
it('addMonths rolls over the year', () => {
|
||||
expect(toISODate(addMonths(new Date(2026, 11, 15), 1))).toBe(
|
||||
'2027-01-15'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventDateKey / eventHour / formatTime', () => {
|
||||
it('eventDateKey takes the first 10 chars', () => {
|
||||
expect(eventDateKey('2026-06-15T10:30:00')).toBe('2026-06-15');
|
||||
expect(eventDateKey('2026-06-15')).toBe('2026-06-15');
|
||||
});
|
||||
|
||||
it('eventHour returns the hour for timed events and null otherwise', () => {
|
||||
expect(eventHour('2026-06-15T10:30:00')).toBe(10);
|
||||
expect(eventHour('2026-06-15')).toBeNull();
|
||||
expect(eventHour('2026-06-15T09:00:00')).toBe(9);
|
||||
});
|
||||
|
||||
it('formatTime renders HH:mm or 終日', () => {
|
||||
expect(formatTime('2026-06-15T10:30:00')).toBe('10:30');
|
||||
expect(formatTime('2026-06-15')).toBe('終日');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventsOnDay', () => {
|
||||
const events = [
|
||||
{ startAt: '2026-06-15T10:00:00', title: 'A' },
|
||||
{ startAt: '2026-06-15', title: 'B' },
|
||||
{ startAt: '2026-06-16T09:00:00', title: 'C' },
|
||||
];
|
||||
|
||||
it('filters events whose start date matches the day key', () => {
|
||||
const result = eventsOnDay(events, '2026-06-15');
|
||||
expect(result.map((e) => e.title)).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('returns empty when nothing matches', () => {
|
||||
expect(eventsOnDay(events, '2026-07-01')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeForView', () => {
|
||||
it('day range covers a single day ending at T23:59:59', () => {
|
||||
const r = rangeForView('day', new Date(2026, 5, 15));
|
||||
expect(r).toEqual({ from: '2026-06-15', to: '2026-06-15T23:59:59' });
|
||||
});
|
||||
|
||||
it('week range covers Sun-Sat ending at T23:59:59', () => {
|
||||
const r = rangeForView('week', new Date(2026, 5, 17));
|
||||
expect(r).toEqual({ from: '2026-06-14', to: '2026-06-20T23:59:59' });
|
||||
});
|
||||
|
||||
it('month range covers the whole grid including overflow days', () => {
|
||||
const r = rangeForView('month', new Date(2026, 5, 15));
|
||||
expect(r.from).toBe('2026-05-31');
|
||||
expect(r.to).toBe('2026-07-04T23:59:59');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepAnchor', () => {
|
||||
it('steps months for month view', () => {
|
||||
expect(toISODate(stepAnchor('month', new Date(2026, 5, 15), 1))).toBe(
|
||||
'2026-07-15'
|
||||
);
|
||||
expect(toISODate(stepAnchor('month', new Date(2026, 5, 15), -1))).toBe(
|
||||
'2026-05-15'
|
||||
);
|
||||
});
|
||||
|
||||
it('steps weeks for week view', () => {
|
||||
expect(toISODate(stepAnchor('week', new Date(2026, 5, 17), 1))).toBe(
|
||||
'2026-06-24'
|
||||
);
|
||||
});
|
||||
|
||||
it('steps days for day view', () => {
|
||||
expect(toISODate(stepAnchor('day', new Date(2026, 5, 15), 1))).toBe(
|
||||
'2026-06-16'
|
||||
);
|
||||
expect(toISODate(stepAnchor('day', new Date(2026, 5, 15), -1))).toBe(
|
||||
'2026-06-14'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user