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
This commit is contained in:
142
repositories/CalendarRepository.ts
Normal file
142
repositories/CalendarRepository.ts
Normal file
@ -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<CalendarEventRow>(
|
||||
`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<CalendarEventRow>(
|
||||
'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<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
168
repositories/MilestoneRepository.ts
Normal file
168
repositories/MilestoneRepository.ts
Normal file
@ -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<MilestoneRow>(
|
||||
'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<MilestoneRow>(
|
||||
'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<string, unknown> = {
|
||||
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<TodoItemRow>(
|
||||
'SELECT * FROM todo_items WHERE milestone_id = @milestoneId AND deleted_at IS NULL',
|
||||
{ milestoneId }
|
||||
);
|
||||
return rows.map(mapTodo);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user