diff --git a/app/projects/[projectId]/calendar/page.tsx b/app/projects/[projectId]/calendar/page.tsx index 6de21fe..f7f0228 100644 --- a/app/projects/[projectId]/calendar/page.tsx +++ b/app/projects/[projectId]/calendar/page.tsx @@ -7,27 +7,34 @@ import { import { Header } from '@/components/layout/Header'; import { ProjectNav } from '@/components/layout/ProjectNav'; import { CalendarEventForm } from '@/components/calendar/CalendarEventForm'; +import { CalendarView } from '@/components/calendar/CalendarView'; +import { + rangeForView, + toISODate, + parseISODate, + type CalendarViewMode, +} from '@/lib/calendar/grid'; import { ForbiddenError, NotFoundError } from '@/lib/errors'; export const dynamic = 'force-dynamic'; -const SOURCE_COLORS: Record = { - event: 'bg-blue-100 text-blue-700', - milestone: 'bg-purple-100 text-purple-700', - todo: 'bg-yellow-100 text-yellow-700', -}; +const VALID_VIEWS: CalendarViewMode[] = ['month', 'week', 'day']; + +function isCalendarView(v: unknown): v is CalendarViewMode { + return typeof v === 'string' && (VALID_VIEWS as string[]).includes(v); +} export default async function CalendarPage({ params, searchParams, }: { params: Promise<{ projectId: string }>; - searchParams: Promise<{ from?: string; to?: string }>; + searchParams: Promise<{ view?: string; date?: string }>; }) { const user = await getCurrentUser(); if (!user) redirect('/login'); const { projectId } = await params; - const { from, to } = await searchParams; + const { view, date } = await searchParams; const projectService = createProjectService(); let project: Awaited>; @@ -40,55 +47,35 @@ export default async function CalendarPage({ throw error; } - // デフォルトは当月 - const now = new Date(); - const defaultFrom = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`; - const rangeFrom = from ?? defaultFrom; - const rangeTo = to ?? defaultFrom.replace(/-01$/, '-28'); + // 表示モードと基準日を解決(不正値はデフォルトへフォールバック) + const resolvedView: CalendarViewMode = isCalendarView(view) ? view : 'month'; + const today = new Date(); + const anchor = + date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? parseISODate(date) : today; + // ビューに応じた取得範囲を計算しイベントを取得。 + // アクセス権は getProject で参加確認済みなのでここでは再チェックしない。 + const range = rangeForView(resolvedView, anchor); const scheduleService = createScheduleService(); - const events = scheduleService.getCalendarEvents(user.id, project.id, { - from: rangeFrom, - to: rangeTo, - }); + const events = scheduleService.getCalendarEvents(user.id, project.id, range); return (
-
+

カレンダー

-

- 期間: {rangeFrom} 〜 {rangeTo} -

- -
- {events.length === 0 ? ( -

- 期間内のイベントはありません。 -

- ) : ( - events.map((e) => ( -
-
-

{e.title}

-

{e.startAt}

-
- - {e.source} - -
- )) - )} -
+ +
); diff --git a/components/calendar/CalendarView.tsx b/components/calendar/CalendarView.tsx new file mode 100644 index 0000000..2c734fb --- /dev/null +++ b/components/calendar/CalendarView.tsx @@ -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(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 ( +
+
+

+ {title} +

+
+
+ {VIEW_LABELS.map((v) => ( + + ))} +
+
+ + + +
+
+
+ + {view === 'month' && ( + + )} + {view === 'week' && ( + + )} + {view === 'day' && ( + + )} + + {selectedDateKey && ( + setSelectedDateKey(null)} + /> + )} +
+ ); +} + +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()]})`; +} diff --git a/components/calendar/DayView.tsx b/components/calendar/DayView.tsx new file mode 100644 index 0000000..6334d40 --- /dev/null +++ b/components/calendar/DayView.tsx @@ -0,0 +1,121 @@ +'use client'; + +import type { CalendarEventView } from '@/services/ScheduleService'; +import { + eventsOnDay, + eventHour, + formatTime, + getDayHours, + toISODate, +} from '@/lib/calendar/grid'; +import { + SOURCE_COLORS, + SOURCE_CHIP_BORDER, + WEEKDAY_LABELS, +} from '@/components/calendar/sourceColors'; + +/** + * 日表示(時間グリッド)。0:00〜23:00 を1時間刻みで表示し、 + * 時刻付きイベントを該当時刻行に、終日(日付のみ)イベントを上部に配置する。 + */ +export function DayView({ + day, + events, + todayKey, + onSelectDate, +}: { + day: Date; + events: CalendarEventView[]; + todayKey: string; + onSelectDate: (dateKey: string) => void; +}) { + const key = toISODate(day); + const dayEvents = eventsOnDay(events, key); + const allDay = dayEvents.filter((e) => eventHour(e.startAt) === null); + const timed = dayEvents.filter((e) => eventHour(e.startAt) !== null); + const hours = getDayHours(); + const isToday = key === todayKey; + + return ( +
+
+ + {isToday && ( + + 今日 + + )} +
+ + {allDay.length > 0 && ( +
+

終日

+
+ {allDay.map((e) => ( + + ))} +
+
+ )} + +
+ {hours.map((h) => { + const hourEvents = timed.filter((e) => eventHour(e.startAt) === h); + return ( +
+
+ {String(h).padStart(2, '0')}:00 +
+
+ {hourEvents.map((e) => ( + + ))} +
+
+ ); + })} +
+
+ ); +} diff --git a/components/calendar/EventDetailDialog.tsx b/components/calendar/EventDetailDialog.tsx new file mode 100644 index 0000000..37771be --- /dev/null +++ b/components/calendar/EventDetailDialog.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import type { CalendarEventView } from '@/services/ScheduleService'; +import { formatTime } from '@/lib/calendar/grid'; +import { + SOURCE_COLORS, + SOURCE_LABELS, +} from '@/components/calendar/sourceColors'; + +/** + * 指定日のイベント詳細ダイアログ。 + * セル内ではtruncateされるタイトルも、ここでは全文と説明を表示する。 + * 開閉時にフォーカスをダイアログへ移し、閉じたら元の要素へ戻す。 + */ +export function EventDetailDialog({ + date, + events, + onClose, +}: { + date: Date; + events: CalendarEventView[]; + onClose: () => void; +}) { + const dialogRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + dialogRef.current?.focus(); + return () => { + previouslyFocused?.focus?.(); + }; + }, []); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + const dateLabel = `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={`${dateLabel}のイベント`} + data-testid="calendar-detail-dialog" + > +
+

{dateLabel}

+ +
+
+ {events.length === 0 ? ( +

+ この日のイベントはありません。 +

+ ) : ( + events.map((e) => ( +
+
+

+ {e.title} +

+ + {SOURCE_LABELS[e.source] ?? e.source} + +
+

+ {formatTime(e.startAt)} + {e.endAt ? ` 〜 ${formatTime(e.endAt)}` : ''} +

+ {e.description && ( +

+ {e.description} +

+ )} +
+ )) + )} +
+
+
+ ); +} diff --git a/components/calendar/MonthView.tsx b/components/calendar/MonthView.tsx new file mode 100644 index 0000000..92b6650 --- /dev/null +++ b/components/calendar/MonthView.tsx @@ -0,0 +1,105 @@ +'use client'; + +import type { CalendarEventView } from '@/services/ScheduleService'; +import { eventsOnDay, toISODate } from '@/lib/calendar/grid'; +import { + SOURCE_COLORS, + SOURCE_CHIP_BORDER, + WEEKDAY_LABELS, +} from '@/components/calendar/sourceColors'; + +const MAX_VISIBLE_CHIPS = 3; + +/** + * 月表示グリッド。日曜始まりの7列グリッドで各日にイベントチップを表示する。 + * 長いタイトルは truncate し、3件を超える分は「+N件」でまとめる。 + */ +export function MonthView({ + events, + weeks, + anchorMonth, + todayKey, + onSelectDate, +}: { + events: CalendarEventView[]; + weeks: Date[][]; + anchorMonth: number; + todayKey: string; + onSelectDate: (dateKey: string) => void; +}) { + return ( +
+
+ {WEEKDAY_LABELS.map((w) => ( +
+ {w} +
+ ))} +
+
+ {weeks.map((week, wi) => ( +
+ {week.map((day) => { + const key = toISODate(day); + const dayEvents = eventsOnDay(events, key); + const inMonth = day.getMonth() === anchorMonth; + const isToday = key === todayKey; + const visible = dayEvents.slice(0, MAX_VISIBLE_CHIPS); + const hidden = dayEvents.length - visible.length; + return ( +
+ +
+ {visible.map((e) => ( + + ))} + {hidden > 0 && ( + + )} +
+
+ ); + })} +
+ ))} +
+
+ ); +} diff --git a/components/calendar/WeekView.tsx b/components/calendar/WeekView.tsx new file mode 100644 index 0000000..51221bf --- /dev/null +++ b/components/calendar/WeekView.tsx @@ -0,0 +1,91 @@ +'use client'; + +import type { CalendarEventView } from '@/services/ScheduleService'; +import { eventsOnDay, formatTime, toISODate } from '@/lib/calendar/grid'; +import { + SOURCE_COLORS, + SOURCE_CHIP_BORDER, + WEEKDAY_LABELS, +} from '@/components/calendar/sourceColors'; + +/** + * 週表示。日〜土の7日を並べ、各日のイベントを開始時刻付きで表示する。 + */ +export function WeekView({ + events, + days, + todayKey, + onSelectDate, +}: { + events: CalendarEventView[]; + days: Date[]; + todayKey: string; + onSelectDate: (dateKey: string) => void; +}) { + return ( +
+
+ {days.map((d) => { + const key = toISODate(d); + return ( + + ); + })} +
+
+ {days.map((d) => { + const key = toISODate(d); + const dayEvents = eventsOnDay(events, key); + return ( +
+
+ {dayEvents.length === 0 ? ( +

-

+ ) : ( + dayEvents.map((e) => ( + + )) + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/components/calendar/sourceColors.ts b/components/calendar/sourceColors.ts new file mode 100644 index 0000000..b5c461e --- /dev/null +++ b/components/calendar/sourceColors.ts @@ -0,0 +1,28 @@ +/** カレンダーイベントの来源ごとの色とラベル。 */ +export const SOURCE_COLORS: Record = { + event: 'bg-blue-100 text-blue-700', + milestone: 'bg-purple-100 text-purple-700', + todo: 'bg-yellow-100 text-yellow-700', +}; + +export const SOURCE_CHIP_BORDER: Record = { + event: 'border-blue-200', + milestone: 'border-purple-200', + todo: 'border-yellow-200', +}; + +export const SOURCE_LABELS: Record = { + event: 'イベント', + milestone: 'マイルストーン', + todo: 'ToDo', +}; + +export const WEEKDAY_LABELS = [ + '日', + '月', + '火', + '水', + '木', + '金', + '土', +] as const; diff --git a/docs/repository-structure.md b/docs/repository-structure.md index 268461e..93e5e92 100644 --- a/docs/repository-structure.md +++ b/docs/repository-structure.md @@ -235,7 +235,7 @@ components/ ├── chat/ # ChatWindow, MessageInput, MessageList ├── todo/ # KanbanBoard, KanbanColumn, TodoCard ├── files/ # FileList, Uploader, Lightbox -├── calendar/ # CalendarView, EventBadge +├── calendar/ # CalendarView, MonthView, WeekView, DayView, EventDetailDialog, CalendarEventForm ├── meetings/ # MeetingForm, ConflictWarning ├── notes/ # NoteEditor, MarkdownPreview └── notifications/ # NotificationList, NotificationBadge diff --git a/lib/calendar/grid.ts b/lib/calendar/grid.ts new file mode 100644 index 0000000..f66c00d --- /dev/null +++ b/lib/calendar/grid.ts @@ -0,0 +1,159 @@ +/** + * カレンダー表示用の純粋な日付計算ヘルパー群。 + * React にも DB にも依存しないので Unit Test が容易。 + * + * 週の開始は日曜日(Sunday=0)。`WEEK_START` を変更すれば月曜始めにも対応可能。 + * イベントは構造的部分型 `{ startAt: string }` を要求するだけなので + * `CalendarEventView` に依存せず、レイヤ依存規則(lib → services 禁止)を守る。 + */ + +/** 週の開始曜日 (0=日曜)。 */ +export const WEEK_START = 0; + +export type CalendarViewMode = 'month' | 'week' | 'day'; + +/** Event 風オブジェクト。startAt だけ必須。 */ +export interface DateableEvent { + startAt: string; +} + +/** Date を `YYYY-MM-DD` へ変換する(ローカルタイム)。 */ +export function toISODate(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}`; +} + +/** `YYYY-MM-DD` をローカル Date(真夜中) へパースする。 */ +export function parseISODate(s: string): Date { + const [y, m, d] = s.split('-').map(Number); + return new Date(y, (m ?? 1) - 1, d ?? 1); +} + +/** n 日加算した新しい Date を返す。 */ +export function addDays(d: Date, n: number): Date { + const r = new Date(d); + r.setDate(r.getDate() + n); + return r; +} + +/** n 週加算した新しい Date を返す。 */ +export function addWeeks(d: Date, n: number): Date { + return addDays(d, n * 7); +} + +/** n 月加算した新しい Date を返す(日付は維持、月跨ぎ時は月末日に丸める)。 */ +export function addMonths(d: Date, n: number): Date { + const r = new Date(d); + r.setMonth(r.getMonth() + n); + return r; +} + +/** 引数の日を含む週の開始日(WEEK_START 基準)を返す。 */ +export function startOfWeek(d: Date): Date { + const r = new Date(d); + r.setHours(0, 0, 0, 0); + const delta = (r.getDay() - WEEK_START + 7) % 7; + r.setDate(r.getDate() - delta); + return r; +} + +/** + * 月カレンダーのグリッド(週の配列)を返す。 + * 前月/翌月の日を含めて日曜始まり・土曜終わりで埋める。 + */ +export function getMonthGrid(year: number, month: number): Date[][] { + const first = new Date(year, month, 1); + const gridStart = startOfWeek(first); + const last = new Date(year, month + 1, 0); + const weeks: Date[][] = []; + let cursor = new Date(gridStart); + while (cursor <= last) { + const week: Date[] = []; + for (let i = 0; i < 7; i++) { + week.push(new Date(cursor)); + cursor = addDays(cursor, 1); + } + weeks.push(week); + } + return weeks; +} + +/** 引数の日を含む週の 7 日分(日〜土)を返す。 */ +export function getWeekDays(anchor: Date): Date[] { + const start = startOfWeek(anchor); + return Array.from({ length: 7 }, (_, i) => addDays(start, i)); +} + +/** 1日の時間グリッド 0..23 を返す。 */ +export function getDayHours(): number[] { + return Array.from({ length: 24 }, (_, i) => i); +} + +/** イベントの開始日を `YYYY-MM-DD` で取り出す(startAt の先頭10文字)。 */ +export function eventDateKey(startAt: string): string { + return startAt.slice(0, 10); +} + +/** + * イベントの開始「時」を返す。時刻なし(日付のみ)なら null(終日扱い)。 + * `2026-06-15T10:00:00` → 10 / `2026-06-15` → null + */ +export function eventHour(startAt: string): number | null { + if (startAt.length <= 10) return null; + const h = parseInt(startAt.slice(11, 13), 10); + return Number.isNaN(h) ? null : h; +} + +/** `YYYY-MM-DDTHH:mm:ss` または `YYYY-MM-DD` から `HH:mm` を取り出す。 */ +export function formatTime(startAt: string): string { + if (startAt.length <= 10) return '終日'; + const hh = startAt.slice(11, 13); + const mm = startAt.slice(14, 16); + return mm ? `${hh}:${mm}` : `${hh}:00`; +} + +/** 指定日のイベントだけを抽出する。 */ +export function eventsOnDay( + events: T[], + dateKey: string +): T[] { + return events.filter((e) => eventDateKey(e.startAt) === dateKey); +} + +/** + * ビューと基準日から ScheduleService に渡す { from, to } を計算する。 + * `to` は `T23:59:59` 付きにし、サービスの文字列比較で当日深夜のイベントも取りこぼさない。 + * month はグリッド全体(前月/翌月の溢れ日を含む)をカバーする。 + */ +export function rangeForView( + view: CalendarViewMode, + anchor: Date +): { from: string; to: string } { + if (view === 'day') { + const d = toISODate(anchor); + return { from: d, to: `${d}T23:59:59` }; + } + if (view === 'week') { + const days = getWeekDays(anchor); + const from = toISODate(days[0]); + const to = `${toISODate(days[6])}T23:59:59`; + return { from, to }; + } + const weeks = getMonthGrid(anchor.getFullYear(), anchor.getMonth()); + const from = toISODate(weeks[0][0]); + const to = `${toISODate(weeks[weeks.length - 1][6])}T23:59:59`; + return { from, to }; +} + +/** ビューに応じて基準日を1単位進める/戻す。 */ +export function stepAnchor( + view: CalendarViewMode, + anchor: Date, + direction: 1 | -1 +): Date { + if (view === 'month') return addMonths(anchor, direction); + if (view === 'week') return addWeeks(anchor, direction); + return addDays(anchor, direction); +} diff --git a/tests/e2e/calendar.spec.ts b/tests/e2e/calendar.spec.ts index 55c0ab6..799815c 100644 --- a/tests/e2e/calendar.spec.ts +++ b/tests/e2e/calendar.spec.ts @@ -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); + }); }); diff --git a/tests/unit/lib/calendar/grid.test.ts b/tests/unit/lib/calendar/grid.test.ts new file mode 100644 index 0000000..1573b45 --- /dev/null +++ b/tests/unit/lib/calendar/grid.test.ts @@ -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' + ); + }); + }); +});