From 31d8ad1325519897def318b80f86cf8c5b608803 Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Thu, 25 Jun 2026 02:11:10 +0200 Subject: [PATCH] feat(m11): calendar + milestones with progress calc, tests, e2e - CalendarRepository (event CRUD, range query, soft-delete, project isolation) + MilestoneRepository (CRUD, findToDosByMilestone) + ScheduleService (aggregated getCalendarEvents = custom events+milestones+todos in range, milestone CRUD, milestone_updated activity, progress auto-calc 0-100) - APIs: calendar events GET/POST/PATCH/DELETE, milestones GET/POST/PATCH + progress - Screens: calendar list view + CalendarEventForm, milestones list with progress bars + MilestoneForm, ProjectNav links - Unit tests (CalendarRepository, MilestoneRepository, ScheduleService incl. progress calc 0/33/100) + e2e calendar --- .../calendar/events/[eventId]/route.ts | 56 ++++ .../[projectId]/calendar/events/route.ts | 56 ++++ .../[projectId]/milestones/[id]/route.ts | 60 +++++ .../projects/[projectId]/milestones/route.ts | 51 ++++ app/projects/[projectId]/calendar/page.tsx | 95 +++++++ app/projects/[projectId]/milestones/page.tsx | 93 +++++++ components/calendar/CalendarEventForm.tsx | 78 ++++++ components/calendar/MilestoneForm.tsx | 73 ++++++ components/layout/ProjectNav.tsx | 6 + lib/api/services.ts | 14 + repositories/CalendarRepository.ts | 142 ++++++++++ repositories/MilestoneRepository.ts | 168 ++++++++++++ services/ScheduleService.ts | 247 ++++++++++++++++++ tests/e2e/calendar.spec.ts | 99 +++++++ .../repositories/CalendarRepository.test.ts | 93 +++++++ .../repositories/MilestoneRepository.test.ts | 84 ++++++ tests/unit/services/ScheduleService.test.ts | 183 +++++++++++++ 17 files changed, 1598 insertions(+) create mode 100644 app/api/projects/[projectId]/calendar/events/[eventId]/route.ts create mode 100644 app/api/projects/[projectId]/calendar/events/route.ts create mode 100644 app/api/projects/[projectId]/milestones/[id]/route.ts create mode 100644 app/api/projects/[projectId]/milestones/route.ts create mode 100644 app/projects/[projectId]/calendar/page.tsx create mode 100644 app/projects/[projectId]/milestones/page.tsx create mode 100644 components/calendar/CalendarEventForm.tsx create mode 100644 components/calendar/MilestoneForm.tsx create mode 100644 repositories/CalendarRepository.ts create mode 100644 repositories/MilestoneRepository.ts create mode 100644 services/ScheduleService.ts create mode 100644 tests/e2e/calendar.spec.ts create mode 100644 tests/unit/repositories/CalendarRepository.test.ts create mode 100644 tests/unit/repositories/MilestoneRepository.test.ts create mode 100644 tests/unit/services/ScheduleService.test.ts diff --git a/app/api/projects/[projectId]/calendar/events/[eventId]/route.ts b/app/api/projects/[projectId]/calendar/events/[eventId]/route.ts new file mode 100644 index 0000000..a456b80 --- /dev/null +++ b/app/api/projects/[projectId]/calendar/events/[eventId]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createScheduleService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ projectId: string; eventId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { eventId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + const service = createScheduleService(); + try { + const event = service.updateEvent(user.id, Number(eventId), { + title: typeof body.title === 'string' ? body.title : undefined, + description: + typeof body.description === 'string' ? body.description : undefined, + startAt: typeof body.startAt === 'string' ? body.startAt : undefined, + endAt: + typeof body.endAt === 'string' + ? body.endAt + : body.endAt === null + ? null + : undefined, + }); + return NextResponse.json({ event }); + } catch (error) { + return handleApiError(error); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string; eventId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { eventId } = await params; + const service = createScheduleService(); + try { + service.deleteEvent(user.id, Number(eventId)); + return NextResponse.json({ ok: true }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/calendar/events/route.ts b/app/api/projects/[projectId]/calendar/events/route.ts new file mode 100644 index 0000000..3cb275a --- /dev/null +++ b/app/api/projects/[projectId]/calendar/events/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createScheduleService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + const from = request.nextUrl.searchParams.get('from') ?? ''; + const to = request.nextUrl.searchParams.get('to') ?? ''; + const service = createScheduleService(); + try { + return NextResponse.json( + service.getCalendarEvents(user.id, Number(projectId), { from, to }) + ); + } catch (error) { + return handleApiError(error); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + const service = createScheduleService(); + try { + const event = service.createEvent(user.id, { + projectId: Number(projectId), + title: String(body.title ?? ''), + type: (typeof body.type === 'string' ? body.type : 'custom') as never, + startAt: String(body.startAt ?? ''), + endAt: typeof body.endAt === 'string' ? body.endAt : null, + description: + typeof body.description === 'string' ? body.description : null, + }); + return NextResponse.json({ event }, { status: 201 }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/milestones/[id]/route.ts b/app/api/projects/[projectId]/milestones/[id]/route.ts new file mode 100644 index 0000000..07918a9 --- /dev/null +++ b/app/api/projects/[projectId]/milestones/[id]/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createScheduleService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ projectId: string; id: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { id } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + const service = createScheduleService(); + try { + const milestone = service.updateMilestone(user.id, Number(id), { + title: typeof body.title === 'string' ? body.title : undefined, + dueDate: + typeof body.dueDate === 'string' + ? body.dueDate + : body.dueDate === null + ? null + : undefined, + status: + typeof body.status === 'string' + ? (body.status as 'open' | 'closed') + : undefined, + description: + typeof body.description === 'string' ? body.description : undefined, + }); + return NextResponse.json({ milestone }); + } catch (error) { + return handleApiError(error); + } +} + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string; id: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { id } = await params; + const service = createScheduleService(); + try { + return NextResponse.json({ + progress: service.getMilestoneProgress(user.id, Number(id)), + }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/milestones/route.ts b/app/api/projects/[projectId]/milestones/route.ts new file mode 100644 index 0000000..8b2b61f --- /dev/null +++ b/app/api/projects/[projectId]/milestones/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createScheduleService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + const service = createScheduleService(); + try { + return NextResponse.json({ + milestones: service.getMilestones(user.id, Number(projectId)), + }); + } catch (error) { + return handleApiError(error); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + const service = createScheduleService(); + try { + const milestone = service.createMilestone(user.id, Number(projectId), { + title: String(body.title ?? ''), + dueDate: typeof body.dueDate === 'string' ? body.dueDate : null, + description: + typeof body.description === 'string' ? body.description : null, + }); + return NextResponse.json({ milestone }, { status: 201 }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/projects/[projectId]/calendar/page.tsx b/app/projects/[projectId]/calendar/page.tsx new file mode 100644 index 0000000..6de21fe --- /dev/null +++ b/app/projects/[projectId]/calendar/page.tsx @@ -0,0 +1,95 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { + createScheduleService, + createProjectService, +} from '@/lib/api/services'; +import { Header } from '@/components/layout/Header'; +import { ProjectNav } from '@/components/layout/ProjectNav'; +import { CalendarEventForm } from '@/components/calendar/CalendarEventForm'; +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', +}; + +export default async function CalendarPage({ + params, + searchParams, +}: { + params: Promise<{ projectId: string }>; + searchParams: Promise<{ from?: string; to?: string }>; +}) { + const user = await getCurrentUser(); + if (!user) redirect('/login'); + const { projectId } = await params; + const { from, to } = await searchParams; + + const projectService = createProjectService(); + let project: Awaited>; + try { + project = projectService.getProject(user.id, Number(projectId)); + } catch (error) { + if (error instanceof ForbiddenError || error instanceof NotFoundError) { + redirect('/dashboard'); + } + 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 scheduleService = createScheduleService(); + const events = scheduleService.getCalendarEvents(user.id, project.id, { + from: rangeFrom, + to: rangeTo, + }); + + return ( +
+
+ +
+

カレンダー

+

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

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

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

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

{e.title}

+

{e.startAt}

+
+ + {e.source} + +
+ )) + )} +
+
+
+ ); +} diff --git a/app/projects/[projectId]/milestones/page.tsx b/app/projects/[projectId]/milestones/page.tsx new file mode 100644 index 0000000..ece8460 --- /dev/null +++ b/app/projects/[projectId]/milestones/page.tsx @@ -0,0 +1,93 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { + createScheduleService, + createProjectService, +} from '@/lib/api/services'; +import { Header } from '@/components/layout/Header'; +import { ProjectNav } from '@/components/layout/ProjectNav'; +import { MilestoneForm } from '@/components/calendar/MilestoneForm'; +import { ForbiddenError, NotFoundError } from '@/lib/errors'; + +export const dynamic = 'force-dynamic'; + +function progressColor(progress: number): string { + if (progress >= 67) return 'bg-green-500'; + if (progress >= 34) return 'bg-yellow-500'; + return 'bg-red-500'; +} + +export default async function MilestonesPage({ + params, +}: { + params: Promise<{ projectId: string }>; +}) { + const user = await getCurrentUser(); + if (!user) redirect('/login'); + const { projectId } = await params; + + const projectService = createProjectService(); + let project: Awaited>; + try { + project = projectService.getProject(user.id, Number(projectId)); + } catch (error) { + if (error instanceof ForbiddenError || error instanceof NotFoundError) { + redirect('/dashboard'); + } + throw error; + } + + const scheduleService = createScheduleService(); + const milestones = scheduleService.getMilestones(user.id, project.id); + + return ( +
+
+ +
+

マイルストーン

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

+ マイルストーンはありません。 +

+ ) : ( + milestones.map((m) => ( +
+
+

{m.title}

+ + {m.status} + {m.dueDate ? ` / 期限: ${m.dueDate}` : ''} + +
+ {m.description && ( +

{m.description}

+ )} +
+
+ 進捗 + + {m.progress}% + +
+
+
+
+
+
+ )) + )} +
+
+
+ ); +} diff --git a/components/calendar/CalendarEventForm.tsx b/components/calendar/CalendarEventForm.tsx new file mode 100644 index 0000000..37e0384 --- /dev/null +++ b/components/calendar/CalendarEventForm.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; + +export function CalendarEventForm({ + projectId, + defaultDate, +}: { + projectId: number; + defaultDate: string; +}) { + const router = useRouter(); + const [title, setTitle] = useState(''); + const [startAt, setStartAt] = useState(`${defaultDate}T10:00:00`); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setLoading(true); + setError(null); + const res = await fetch(`/api/projects/${projectId}/calendar/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, type: 'custom', startAt }), + }); + setLoading(false); + if (res.ok) { + setTitle(''); + router.refresh(); + } else { + const b = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + setError(b?.error?.message ?? '作成に失敗しました'); + } + } + + return ( +
+
+ + setTitle(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + required + /> +
+
+ + setStartAt(e.target.value)} + className="mt-1 rounded border px-3 py-2" + /> +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/calendar/MilestoneForm.tsx b/components/calendar/MilestoneForm.tsx new file mode 100644 index 0000000..6b964dc --- /dev/null +++ b/components/calendar/MilestoneForm.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; + +export function MilestoneForm({ projectId }: { projectId: number }) { + const router = useRouter(); + const [title, setTitle] = useState(''); + const [dueDate, setDueDate] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setLoading(true); + setError(null); + const res = await fetch(`/api/projects/${projectId}/milestones`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, dueDate: dueDate || null }), + }); + setLoading(false); + if (res.ok) { + setTitle(''); + setDueDate(''); + router.refresh(); + } else { + const b = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + setError(b?.error?.message ?? '作成に失敗しました'); + } + } + + return ( +
+
+ + setTitle(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + required + /> +
+
+ + setDueDate(e.target.value)} + className="mt-1 rounded border px-3 py-2" + /> +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/layout/ProjectNav.tsx b/components/layout/ProjectNav.tsx index dcef510..2238723 100644 --- a/components/layout/ProjectNav.tsx +++ b/components/layout/ProjectNav.tsx @@ -5,6 +5,8 @@ const NAV_ITEMS = [ { href: '/chat', label: 'チャット' }, { href: '/todos', label: 'ToDo' }, { href: '/files', label: 'ファイル' }, + { href: '/calendar', label: 'カレンダー' }, + { href: '/milestones', label: 'マイルストーン' }, { href: '/members', label: 'メンバー' }, { href: '/activity', label: 'アクティビティ' }, { href: '/settings', label: '設定' }, @@ -26,6 +28,8 @@ export function ProjectNav({ | 'chat' | 'todos' | 'files' + | 'calendar' + | 'milestones' | 'members' | 'activity' | 'settings'; @@ -37,6 +41,8 @@ export function ProjectNav({ chat: active === 'chat', todos: active === 'todos', files: active === 'files', + calendar: active === 'calendar', + milestones: active === 'milestones', members: active === 'members', activity: active === 'activity', settings: active === 'settings', diff --git a/lib/api/services.ts b/lib/api/services.ts index 97b6bc0..7a58e34 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -18,6 +18,9 @@ import { TodoService } from '@/services/TodoService'; import { TodoRepository } from '@/repositories/TodoRepository'; import { FileStorageService } from '@/services/FileStorageService'; import { FileRepository } from '@/repositories/FileRepository'; +import { ScheduleService } from '@/services/ScheduleService'; +import { CalendarRepository } from '@/repositories/CalendarRepository'; +import { MilestoneRepository } from '@/repositories/MilestoneRepository'; /** * Route Handler用に各Repository/Serviceを構築するファクトリ。 @@ -96,6 +99,17 @@ export function createFileStorageService(): FileStorageService { ); } +export function createScheduleService(): ScheduleService { + const db = getDb(); + return new ScheduleService( + new CalendarRepository(db), + new MilestoneRepository(db), + new TodoRepository(db), + new ProjectMemberRepository(db), + new ActivityLogService(new ActivityLogRepository(db)) + ); +} + export function createUserRepository(): UserRepository { return new UserRepository(getDb()); } diff --git a/repositories/CalendarRepository.ts b/repositories/CalendarRepository.ts new file mode 100644 index 0000000..2a81ef6 --- /dev/null +++ b/repositories/CalendarRepository.ts @@ -0,0 +1,142 @@ +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import type { CalendarEvent, CalendarEventType } from '@/lib/types'; + +interface CalendarEventRow { + id: number; + project_id: number; + title: string; + description: string | null; + type: string; + start_at: string; + end_at: string | null; + created_by_id: number; + related_todo_id: number | null; + related_milestone_id: number | null; + related_meeting_id: number | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +function mapEvent(row: CalendarEventRow): CalendarEvent { + return { + id: row.id, + projectId: row.project_id, + title: row.title, + description: row.description, + type: row.type as CalendarEventType, + startAt: row.start_at, + endAt: row.end_at, + createdById: row.created_by_id, + relatedTodoId: row.related_todo_id, + relatedMilestoneId: row.related_milestone_id, + relatedMeetingId: row.related_meeting_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + deletedAt: row.deleted_at, + }; +} + +export interface CreateEventInput { + projectId: number; + title: string; + description?: string | null; + type: CalendarEventType; + startAt: string; + endAt?: string | null; + createdById: number; +} + +export class CalendarRepository { + constructor(private readonly db: SqliteDatabase) {} + + findByProjectInRange( + projectId: number, + from: string, + to: string + ): CalendarEvent[] { + const rows = this.db.query( + `SELECT * FROM calendar_events + WHERE project_id = @projectId AND deleted_at IS NULL + AND start_at >= @from AND start_at <= @to + ORDER BY start_at ASC, id ASC`, + { projectId, from, to } + ); + return rows.map(mapEvent); + } + + findEventById(id: number): CalendarEvent | null { + const row = this.db.get( + 'SELECT * FROM calendar_events WHERE id = @id AND deleted_at IS NULL', + { id } + ); + return row ? mapEvent(row) : null; + } + + create(input: CreateEventInput): CalendarEvent { + const now = new Date().toISOString(); + const result = this.db.execute( + `INSERT INTO calendar_events (project_id, title, description, type, start_at, end_at, created_by_id, related_todo_id, related_milestone_id, related_meeting_id, created_at, updated_at, deleted_at) + VALUES (@projectId, @title, @description, @type, @startAt, @endAt, @createdById, NULL, NULL, NULL, @createdAt, @updatedAt, NULL)`, + { + projectId: input.projectId, + title: input.title, + description: input.description ?? null, + type: input.type, + startAt: input.startAt, + endAt: input.endAt ?? null, + createdById: input.createdById, + createdAt: now, + updatedAt: now, + } + ); + const created = this.findEventById(Number(result.lastInsertRowid)); + if (!created) throw new Error('Failed to create calendar event'); + return created; + } + + update( + id: number, + input: { + title?: string; + description?: string | null; + startAt?: string; + endAt?: string | null; + } + ): CalendarEvent | null { + const fields: string[] = ['updated_at = @updatedAt']; + const params: Record = { + updatedAt: new Date().toISOString(), + id, + }; + if (input.title !== undefined) { + fields.push('title = @title'); + params.title = input.title; + } + if (input.description !== undefined) { + fields.push('description = @description'); + params.description = input.description; + } + if (input.startAt !== undefined) { + fields.push('start_at = @startAt'); + params.startAt = input.startAt; + } + if (input.endAt !== undefined) { + fields.push('end_at = @endAt'); + params.endAt = input.endAt; + } + this.db.execute( + `UPDATE calendar_events SET ${fields.join(', ')} WHERE id = @id AND deleted_at IS NULL`, + params + ); + return this.findEventById(id); + } + + delete(id: number): boolean { + const result = this.db.execute( + 'UPDATE calendar_events SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL', + { now: new Date().toISOString(), id } + ); + return result.changes > 0; + } +} diff --git a/repositories/MilestoneRepository.ts b/repositories/MilestoneRepository.ts new file mode 100644 index 0000000..c0e7108 --- /dev/null +++ b/repositories/MilestoneRepository.ts @@ -0,0 +1,168 @@ +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import type { Milestone, MilestoneStatus, TodoItem } from '@/lib/types'; + +interface MilestoneRow { + id: number; + project_id: number; + title: string; + description: string | null; + due_date: string | null; + status: string; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +interface TodoItemRow { + id: number; + project_id: number; + column_id: number; + title: string; + description: string | null; + assignee_id: number | null; + creator_id: number; + priority: string; + start_date: string | null; + due_date: string | null; + completed_at: string | null; + order_index: number; + milestone_id: number | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +function mapMilestone(row: MilestoneRow): Milestone { + return { + id: row.id, + projectId: row.project_id, + title: row.title, + description: row.description, + dueDate: row.due_date, + status: row.status as MilestoneStatus, + createdAt: row.created_at, + updatedAt: row.updated_at, + deletedAt: row.deleted_at, + }; +} + +function mapTodo(row: TodoItemRow): TodoItem { + return { + id: row.id, + projectId: row.project_id, + columnId: row.column_id, + title: row.title, + description: row.description, + assigneeId: row.assignee_id, + creatorId: row.creator_id, + priority: row.priority as never, + startDate: row.start_date, + dueDate: row.due_date, + completedAt: row.completed_at, + orderIndex: row.order_index, + milestoneId: row.milestone_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + deletedAt: row.deleted_at, + }; +} + +export interface CreateMilestoneInput { + projectId: number; + title: string; + description?: string | null; + dueDate?: string | null; +} + +export class MilestoneRepository { + constructor(private readonly db: SqliteDatabase) {} + + findByProject(projectId: number): Milestone[] { + const rows = this.db.query( + 'SELECT * FROM milestones WHERE project_id = @projectId AND deleted_at IS NULL ORDER BY due_date ASC, id ASC', + { projectId } + ); + return rows.map(mapMilestone); + } + + findById(id: number): Milestone | null { + const row = this.db.get( + 'SELECT * FROM milestones WHERE id = @id AND deleted_at IS NULL', + { id } + ); + return row ? mapMilestone(row) : null; + } + + create(input: CreateMilestoneInput): Milestone { + const now = new Date().toISOString(); + const result = this.db.execute( + `INSERT INTO milestones (project_id, title, description, due_date, status, created_at, updated_at, deleted_at) + VALUES (@projectId, @title, @description, @dueDate, 'open', @createdAt, @updatedAt, NULL)`, + { + projectId: input.projectId, + title: input.title, + description: input.description ?? null, + dueDate: input.dueDate ?? null, + createdAt: now, + updatedAt: now, + } + ); + const created = this.findById(Number(result.lastInsertRowid)); + if (!created) throw new Error('Failed to create milestone'); + return created; + } + + update( + id: number, + input: { + title?: string; + description?: string | null; + dueDate?: string | null; + status?: MilestoneStatus; + } + ): Milestone | null { + const fields: string[] = ['updated_at = @updatedAt']; + const params: Record = { + updatedAt: new Date().toISOString(), + id, + }; + if (input.title !== undefined) { + fields.push('title = @title'); + params.title = input.title; + } + if (input.description !== undefined) { + fields.push('description = @description'); + params.description = input.description; + } + if (input.dueDate !== undefined) { + fields.push('due_date = @dueDate'); + params.dueDate = input.dueDate; + } + if (input.status !== undefined) { + fields.push('status = @status'); + params.status = input.status; + } + this.db.execute( + `UPDATE milestones SET ${fields.join(', ')} WHERE id = @id AND deleted_at IS NULL`, + params + ); + return this.findById(id); + } + + delete(id: number): boolean { + const result = this.db.execute( + 'UPDATE milestones SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL', + { now: new Date().toISOString(), id } + ); + return result.changes > 0; + } + + /** マイルストーンに紐づくToDo一覧(論理削除除外) */ + findToDosByMilestone(milestoneId: number): TodoItem[] { + const rows = this.db.query( + 'SELECT * FROM todo_items WHERE milestone_id = @milestoneId AND deleted_at IS NULL', + { milestoneId } + ); + return rows.map(mapTodo); + } +} diff --git a/services/ScheduleService.ts b/services/ScheduleService.ts new file mode 100644 index 0000000..81db412 --- /dev/null +++ b/services/ScheduleService.ts @@ -0,0 +1,247 @@ +import { + CalendarRepository, + type CreateEventInput, +} from '@/repositories/CalendarRepository'; +import { + MilestoneRepository, + type CreateMilestoneInput, +} from '@/repositories/MilestoneRepository'; +import { TodoRepository } from '@/repositories/TodoRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +import { ActivityLogService } from '@/services/ActivityLogService'; +import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; +import type { + CalendarEvent, + CalendarEventType, + Milestone, + MilestoneStatus, +} from '@/lib/types'; + +/** カレンダー集約ビュー(複数ソースを統一表示) */ +export interface CalendarEventView { + key: string; + projectId: number; + title: string; + description: string | null; + type: CalendarEventType; + startAt: string; + endAt: string | null; + source: 'event' | 'milestone' | 'todo'; + refId: number; +} + +export interface MilestoneWithProgress extends Milestone { + progress: number; +} + +export interface UpdateMilestoneInput { + title?: string; + description?: string | null; + dueDate?: string | null; + status?: MilestoneStatus; +} + +/** + * カレンダーイベント集約とマイルストーン管理を担うService。 + * カレンダーイベントの集計(カスタムイベント+マイルストーン+ToDo)と + * マイルストーン進捗率自動計算を行う。 + */ +export class ScheduleService { + constructor( + private readonly calendarRepository: CalendarRepository, + private readonly milestoneRepository: MilestoneRepository, + private readonly todoRepository: TodoRepository, + private readonly projectMemberRepository: ProjectMemberRepository, + private readonly activityLogService: ActivityLogService + ) {} + + getCalendarEvents( + actorId: number, + projectId: number, + range: { from: string; to: string } + ): CalendarEventView[] { + this.requireMember(projectId, actorId); + const views: CalendarEventView[] = []; + + for (const e of this.calendarRepository.findByProjectInRange( + projectId, + range.from, + range.to + )) { + views.push({ + key: `event-${e.id}`, + projectId, + title: e.title, + description: e.description, + type: e.type, + startAt: e.startAt, + endAt: e.endAt, + source: 'event', + refId: e.id, + }); + } + for (const m of this.milestoneRepository.findByProject(projectId)) { + if (!m.dueDate) continue; + if (m.dueDate >= range.from && m.dueDate <= range.to) { + views.push({ + key: `milestone-${m.id}`, + projectId, + title: `マイルストーン: ${m.title}`, + description: m.description, + type: 'milestone', + startAt: m.dueDate, + endAt: m.dueDate, + source: 'milestone', + refId: m.id, + }); + } + } + for (const t of this.todoRepository.findItemsByProject(projectId)) { + const start = t.dueDate ?? t.startDate; + if (!start) continue; + if (start >= range.from && start <= range.to) { + views.push({ + key: `todo-${t.id}`, + projectId, + title: `ToDo: ${t.title}`, + description: t.description, + type: 'todo', + startAt: start, + endAt: t.dueDate ?? null, + source: 'todo', + refId: t.id, + }); + } + } + return views.sort((a, b) => + a.startAt < b.startAt ? -1 : a.startAt > b.startAt ? 1 : 0 + ); + } + + // ----- calendar events ----- + createEvent( + actorId: number, + input: Omit & { + projectId: number; + } + ): CalendarEvent { + this.requireMember(input.projectId, actorId); + if (!input.title.trim()) { + throw new ValidationError('タイトルを入力してください', 'title'); + } + return this.calendarRepository.create({ + projectId: input.projectId, + title: input.title, + description: input.description ?? null, + type: input.type, + startAt: input.startAt, + endAt: input.endAt ?? null, + createdById: actorId, + }); + } + + updateEvent( + actorId: number, + eventId: number, + input: { + title?: string; + description?: string | null; + startAt?: string; + endAt?: string | null; + } + ): CalendarEvent { + const event = this.calendarRepository.findEventById(eventId); + if (!event) throw new NotFoundError('CalendarEvent', eventId); + this.requireMember(event.projectId, actorId); + const updated = this.calendarRepository.update(eventId, input); + if (!updated) throw new NotFoundError('CalendarEvent', eventId); + return updated; + } + + deleteEvent(actorId: number, eventId: number): void { + const event = this.calendarRepository.findEventById(eventId); + if (!event) throw new NotFoundError('CalendarEvent', eventId); + this.requireMember(event.projectId, actorId); + const role = this.projectMemberRepository.getRole(event.projectId, actorId); + if (event.createdById !== actorId && role !== 'admin') { + throw new ForbiddenError('作成者または管理者のみ削除できます'); + } + this.calendarRepository.delete(eventId); + } + + // ----- milestones ----- + getMilestones(actorId: number, projectId: number): MilestoneWithProgress[] { + this.requireMember(projectId, actorId); + return this.milestoneRepository.findByProject(projectId).map((m) => ({ + ...m, + progress: this.calcMilestoneProgress(m.id), + })); + } + + createMilestone( + actorId: number, + projectId: number, + input: Omit + ): Milestone { + this.requireMember(projectId, actorId); + if (!input.title.trim()) { + throw new ValidationError('タイトルを入力してください', 'title'); + } + return this.milestoneRepository.create({ ...input, projectId }); + } + + updateMilestone( + actorId: number, + milestoneId: number, + input: UpdateMilestoneInput + ): Milestone { + const milestone = this.milestoneRepository.findById(milestoneId); + if (!milestone) throw new NotFoundError('Milestone', milestoneId); + this.requireMember(milestone.projectId, actorId); + const updated = this.milestoneRepository.update(milestoneId, input); + if (!updated) throw new NotFoundError('Milestone', milestoneId); + this.activityLogService.logActivity({ + projectId: milestone.projectId, + actorId, + action: 'milestone_updated', + targetType: 'milestone', + targetId: milestoneId, + }); + return updated; + } + + deleteMilestone(actorId: number, milestoneId: number): void { + const milestone = this.milestoneRepository.findById(milestoneId); + if (!milestone) throw new NotFoundError('Milestone', milestoneId); + this.requireMember(milestone.projectId, actorId); + const role = this.projectMemberRepository.getRole( + milestone.projectId, + actorId + ); + if (role !== 'admin') { + throw new ForbiddenError('管理者のみ削除できます'); + } + this.milestoneRepository.delete(milestoneId); + } + + getMilestoneProgress(actorId: number, milestoneId: number): number { + const milestone = this.milestoneRepository.findById(milestoneId); + if (!milestone) throw new NotFoundError('Milestone', milestoneId); + this.requireMember(milestone.projectId, actorId); + return this.calcMilestoneProgress(milestoneId); + } + + /** 進捗率: 関連ToDoの完了率(0件時0%) */ + calcMilestoneProgress(milestoneId: number): number { + const todos = this.milestoneRepository.findToDosByMilestone(milestoneId); + if (todos.length === 0) return 0; + const completed = todos.filter((t) => t.completedAt !== null).length; + return Math.round((completed / todos.length) * 100); + } + + private requireMember(projectId: number, actorId: number): void { + if (!this.projectMemberRepository.isMember(projectId, actorId)) { + throw new ForbiddenError('プロジェクトに参加していません'); + } + } +} diff --git a/tests/e2e/calendar.spec.ts b/tests/e2e/calendar.spec.ts new file mode 100644 index 0000000..55c0ab6 --- /dev/null +++ b/tests/e2e/calendar.spec.ts @@ -0,0 +1,99 @@ +import { test, expect } from '@playwright/test'; + +function unique(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +async function setupOwner(page: import('@playwright/test').Page) { + const email = unique('owner') + '@example.com'; + await page.goto('/login'); + await page.getByRole('button', { name: '新規登録はこちら' }).click(); + await page.getByLabel('表示名').fill('Owner'); + await page.getByLabel('メールアドレス').fill(email); + await page.getByLabel('パスワード').fill('password123'); + await page.getByRole('button', { name: '登録する' }).click(); + await expect(page).toHaveURL(/\/dashboard/); + await page.getByLabel('プロジェクト名').fill(unique('Proj')); + await page.getByRole('button', { name: '新規プロジェクト' }).click(); + await expect(page).toHaveURL(/\/projects\/\d+$/); + return Number(page.url().match(/\/projects\/(\d+)/)![1]); +} + +test.describe('calendar & milestones', () => { + test('milestone progress reflects related todo completion; calendar aggregates', async ({ + page, + }) => { + const projectId = await setupOwner(page); + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, '0'); + const midMonth = `${y}-${m}-15`; + + // マイルストーン作成 + const msRes = await page.request.post( + `/api/projects/${projectId}/milestones`, + { data: { title: unique('MS'), dueDate: midMonth } } + ); + expect(msRes.ok()).toBeTruthy(); + const { milestone } = (await msRes.json()) as { milestone: { id: number } }; + + // ToDoを作成しマイルストーンに紐付け + const cols = ( + (await ( + await page.request.get(`/api/projects/${projectId}/todos/columns`) + ).json()) as { + columns: { id: number }[]; + } + ).columns; + const itemRes = await page.request.post( + `/api/projects/${projectId}/todos/items`, + { + data: { title: 'linked task', columnId: cols[0].id, dueDate: midMonth }, + } + ); + const { item } = (await itemRes.json()) as { item: { id: number } }; + await page.request.patch( + `/api/projects/${projectId}/todos/items/${item.id}`, + { + data: { milestoneId: milestone.id }, + } + ); + + // マイルストーン画面: 進捗 0% + await page.goto(`/projects/${projectId}/milestones`); + await expect(page.getByTestId(`milestone-${milestone.id}`)).toBeVisible(); + await expect( + page.getByTestId(`milestone-progress-${milestone.id}`) + ).toHaveText('0%'); + + // ToDo完了 → 進捗 100% + await page.request.patch( + `/api/projects/${projectId}/todos/items/${item.id}`, + { + data: { toggleComplete: true }, + } + ); + await page.reload(); + await expect( + page.getByTestId(`milestone-progress-${milestone.id}`) + ).toHaveText('100%'); + + // カレンダーイベント作成 + const evRes = await page.request.post( + `/api/projects/${projectId}/calendar/events`, + { + data: { + title: unique('Event'), + type: 'custom', + startAt: `${midMonth}T10:00:00`, + }, + } + ); + expect(evRes.ok()).toBeTruthy(); + + // カレンダー画面にイベント+マイルストーン+ToDoが集約表示される + await page.goto(`/projects/${projectId}/calendar`); + await expect(page.getByText(/マイルストーン:/)).toBeVisible(); + await expect(page.getByText(/ToDo:/)).toBeVisible(); + }); +}); diff --git a/tests/unit/repositories/CalendarRepository.test.ts b/tests/unit/repositories/CalendarRepository.test.ts new file mode 100644 index 0000000..f0ee77c --- /dev/null +++ b/tests/unit/repositories/CalendarRepository.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; +import { ProjectRepository } from '@/repositories/ProjectRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +import { CalendarRepository } from '@/repositories/CalendarRepository'; + +describe('CalendarRepository', () => { + let db: SqliteDatabase; + let repo: CalendarRepository; + let projectId: number; + let userId: number; + + beforeEach(() => { + db = createMigratedTestDb(); + repo = new CalendarRepository(db); + userId = new UserRepository(db).create({ + name: 'U', + email: 'u@example.com', + passwordHash: 'h', + }).id; + projectId = new ProjectRepository(db).create({ + name: 'P', + ownerId: userId, + }).id; + new ProjectMemberRepository(db).add(projectId, userId, 'admin'); + }); + + afterEach(() => db.close()); + + function createEvent(title: string, startAt: string) { + return repo.create({ + projectId, + title, + type: 'custom', + startAt, + createdById: userId, + }); + } + + it('creates and finds an event by id', () => { + const e = createEvent('E', '2026-06-15T10:00:00'); + expect(repo.findEventById(e.id)?.title).toBe('E'); + }); + + it('finds events in range and excludes out-of-range', () => { + createEvent('in', '2026-06-15T10:00:00'); + createEvent('out', '2026-07-15T10:00:00'); + const events = repo.findByProjectInRange( + projectId, + '2026-06-01', + '2026-06-30' + ); + expect(events).toHaveLength(1); + expect(events[0].title).toBe('in'); + }); + + it('excludes soft-deleted events', () => { + const e = createEvent('E', '2026-06-15T10:00:00'); + repo.delete(e.id); + expect(repo.findEventById(e.id)).toBeNull(); + expect( + repo.findByProjectInRange(projectId, '2026-06-01', '2026-06-30') + ).toHaveLength(0); + }); + + it('isolates events by project', () => { + createEvent('mine', '2026-06-15T10:00:00'); + const p2 = new ProjectRepository(db).create({ + name: 'P2', + ownerId: userId, + }).id; + repo.create({ + projectId: p2, + title: 'theirs', + type: 'custom', + startAt: '2026-06-15T10:00:00', + createdById: userId, + }); + expect( + repo.findByProjectInRange(projectId, '2026-06-01', '2026-06-30') + ).toHaveLength(1); + expect( + repo.findByProjectInRange(p2, '2026-06-01', '2026-06-30') + ).toHaveLength(1); + }); + + it('updates an event', () => { + const e = createEvent('E', '2026-06-15T10:00:00'); + expect(repo.update(e.id, { title: 'E2' })?.title).toBe('E2'); + }); +}); diff --git a/tests/unit/repositories/MilestoneRepository.test.ts b/tests/unit/repositories/MilestoneRepository.test.ts new file mode 100644 index 0000000..e5026fa --- /dev/null +++ b/tests/unit/repositories/MilestoneRepository.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; +import { ProjectRepository } from '@/repositories/ProjectRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +import { TodoRepository } from '@/repositories/TodoRepository'; +import { MilestoneRepository } from '@/repositories/MilestoneRepository'; + +describe('MilestoneRepository', () => { + let db: SqliteDatabase; + let repo: MilestoneRepository; + let todoRepo: TodoRepository; + let projectId: number; + let userId: number; + let columnId: number; + + beforeEach(() => { + db = createMigratedTestDb(); + repo = new MilestoneRepository(db); + todoRepo = new TodoRepository(db); + userId = new UserRepository(db).create({ + name: 'U', + email: 'u@example.com', + passwordHash: 'h', + }).id; + projectId = new ProjectRepository(db).create({ + name: 'P', + ownerId: userId, + }).id; + new ProjectMemberRepository(db).add(projectId, userId, 'admin'); + columnId = todoRepo.createColumn({ + projectId, + name: 'Todo', + orderIndex: 0, + }).id; + }); + + afterEach(() => db.close()); + + it('creates and lists milestones ordered by due_date', () => { + repo.create({ projectId, title: 'M2', dueDate: '2026-12-31' }); + repo.create({ projectId, title: 'M1', dueDate: '2026-06-30' }); + const list = repo.findByProject(projectId); + expect(list.map((m) => m.title)).toEqual(['M1', 'M2']); + }); + + it('updates and deletes a milestone', () => { + const m = repo.create({ projectId, title: 'M' }); + expect(repo.update(m.id, { status: 'closed' })?.status).toBe('closed'); + repo.delete(m.id); + expect(repo.findById(m.id)).toBeNull(); + }); + + it('excludes soft-deleted milestones', () => { + const m = repo.create({ projectId, title: 'M' }); + repo.delete(m.id); + expect(repo.findByProject(projectId)).toHaveLength(0); + }); + + it('findToDosByMilestone returns only linked, non-deleted todos', () => { + const m = repo.create({ projectId, title: 'M' }); + const t1 = todoRepo.createItem({ + projectId, + columnId, + title: 't1', + creatorId: userId, + orderIndex: 0, + }); + const t2 = todoRepo.createItem({ + projectId, + columnId, + title: 't2', + creatorId: userId, + orderIndex: 1, + }); + todoRepo.updateItem(t1.id, { milestoneId: m.id }); + todoRepo.updateItem(t2.id, { milestoneId: m.id }); + todoRepo.deleteItem(t2.id); // soft delete + const todos = repo.findToDosByMilestone(m.id); + expect(todos).toHaveLength(1); + expect(todos[0].id).toBe(t1.id); + }); +}); diff --git a/tests/unit/services/ScheduleService.test.ts b/tests/unit/services/ScheduleService.test.ts new file mode 100644 index 0000000..95298a6 --- /dev/null +++ b/tests/unit/services/ScheduleService.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; +import { ProjectRepository } from '@/repositories/ProjectRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +import { CalendarRepository } from '@/repositories/CalendarRepository'; +import { MilestoneRepository } from '@/repositories/MilestoneRepository'; +import { TodoRepository } from '@/repositories/TodoRepository'; +import { ActivityLogRepository } from '@/repositories/ActivityLogRepository'; +import { ActivityLogService } from '@/services/ActivityLogService'; +import { ScheduleService } from '@/services/ScheduleService'; +import { ForbiddenError, NotFoundError } from '@/lib/errors'; + +function makeService(db: SqliteDatabase) { + return new ScheduleService( + new CalendarRepository(db), + new MilestoneRepository(db), + new TodoRepository(db), + new ProjectMemberRepository(db), + new ActivityLogService(new ActivityLogRepository(db)) + ); +} + +describe('ScheduleService', () => { + let db: SqliteDatabase; + let service: ScheduleService; + let projectId: number; + let authorId: number; + let outsiderId: number; + let columnId: number; + + beforeEach(() => { + db = createMigratedTestDb(); + service = makeService(db); + const users = new UserRepository(db); + authorId = users.create({ + name: 'A', + email: 'a@example.com', + passwordHash: 'h', + }).id; + outsiderId = users.create({ + name: 'O', + email: 'o@example.com', + passwordHash: 'h', + }).id; + projectId = new ProjectRepository(db).create({ + name: 'P', + ownerId: authorId, + }).id; + const members = new ProjectMemberRepository(db); + members.add(projectId, authorId, 'admin'); + columnId = new TodoRepository(db).createColumn({ + projectId, + name: 'Todo', + orderIndex: 0, + }).id; + }); + + afterEach(() => db.close()); + + describe('getCalendarEvents (aggregation)', () => { + it('aggregates custom events + milestones + todos within range', () => { + service.createEvent(authorId, { + projectId, + title: 'Meeting', + type: 'meeting', + startAt: '2026-06-10T10:00:00', + }); + service.createMilestone(authorId, projectId, { + title: 'M1', + dueDate: '2026-06-20', + }); + const todoRepo = new TodoRepository(db); + todoRepo.createItem({ + projectId, + columnId, + title: 'task', + creatorId: authorId, + orderIndex: 0, + dueDate: '2026-06-15', + }); + const views = service.getCalendarEvents(authorId, projectId, { + from: '2026-06-01', + to: '2026-06-30', + }); + const sources = views.map((v) => v.source).sort(); + expect(sources).toEqual(['event', 'milestone', 'todo']); + expect(views[0].startAt).toBe('2026-06-10T10:00:00'); // earliest first + }); + + it('excludes items outside the range', () => { + service.createMilestone(authorId, projectId, { + title: 'M', + dueDate: '2026-07-15', + }); + const views = service.getCalendarEvents(authorId, projectId, { + from: '2026-06-01', + to: '2026-06-30', + }); + expect(views).toHaveLength(0); + }); + + it('forbids a non-member', () => { + expect(() => + service.getCalendarEvents(outsiderId, projectId, { + from: '2026-06-01', + to: '2026-06-30', + }) + ).toThrow(ForbiddenError); + }); + }); + + describe('milestone progress', () => { + it('returns 0% when there are no related todos', () => { + const m = service.createMilestone(authorId, projectId, { title: 'M' }); + expect(service.calcMilestoneProgress(m.id)).toBe(0); + expect(service.getMilestoneProgress(authorId, m.id)).toBe(0); + }); + + it('returns the completion ratio rounded', () => { + const m = service.createMilestone(authorId, projectId, { title: 'M' }); + const todoRepo = new TodoRepository(db); + const t1 = todoRepo.createItem({ + projectId, + columnId, + title: 'a', + creatorId: authorId, + orderIndex: 0, + }); + const t2 = todoRepo.createItem({ + projectId, + columnId, + title: 'b', + creatorId: authorId, + orderIndex: 1, + }); + const t3 = todoRepo.createItem({ + projectId, + columnId, + title: 'c', + creatorId: authorId, + orderIndex: 2, + }); + for (const t of [t1, t2, t3]) + todoRepo.updateItem(t.id, { milestoneId: m.id }); + // 1/3 完了 + todoRepo.updateItem(t1.id, { completedAt: '2026-06-01T00:00:00.000Z' }); + expect(service.calcMilestoneProgress(m.id)).toBe(33); + // 3/3 完了 → 100 + todoRepo.updateItem(t2.id, { completedAt: '2026-06-02T00:00:00.000Z' }); + todoRepo.updateItem(t3.id, { completedAt: '2026-06-03T00:00:00.000Z' }); + expect(service.calcMilestoneProgress(m.id)).toBe(100); + }); + + it('getMilestones attaches progress', () => { + const m = service.createMilestone(authorId, projectId, { title: 'M' }); + const list = service.getMilestones(authorId, projectId); + expect(list[0].progress).toBe(0); + expect(list[0].id).toBe(m.id); + }); + }); + + describe('milestone CRUD', () => { + it('update logs milestone_updated activity', () => { + const m = service.createMilestone(authorId, projectId, { title: 'M' }); + service.updateMilestone(authorId, m.id, { status: 'closed' }); + expect( + new ActivityLogRepository(db) + .findByProject(projectId) + .items.some((l) => l.action === 'milestone_updated') + ).toBe(true); + }); + + it('delete requires admin', () => { + const m = service.createMilestone(authorId, projectId, { title: 'M' }); + service.deleteMilestone(authorId, m.id); // authorId is admin + expect(() => + service.updateMilestone(authorId, m.id, { title: 'x' }) + ).toThrow(NotFoundError); + }); + }); +});