From 532f5a39a0078ddf44860ac94a019ba761d1bf84 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 21:30:01 -0600 Subject: [PATCH] feat(#41): implement widget system backend (closes #41) --- .../widgets/dto/calendar-preview-query.dto.ts | 34 ++ apps/api/src/widgets/dto/chart-query.dto.ts | 38 ++ .../widgets/dto/create-widget-config.dto.ts | 46 ++ apps/api/src/widgets/dto/index.ts | 10 + apps/api/src/widgets/dto/list-query.dto.ts | 48 ++ .../src/widgets/dto/stat-card-query.dto.ts | 27 + .../widgets/dto/update-widget-config.dto.ts | 7 + apps/api/src/widgets/widget-data.service.ts | 571 ++++++++++++++++++ apps/api/src/widgets/widgets.controller.ts | 61 +- apps/api/src/widgets/widgets.module.ts | 5 +- packages/shared/src/types/widget.types.ts | 86 ++- 11 files changed, 927 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/widgets/dto/calendar-preview-query.dto.ts create mode 100644 apps/api/src/widgets/dto/chart-query.dto.ts create mode 100644 apps/api/src/widgets/dto/create-widget-config.dto.ts create mode 100644 apps/api/src/widgets/dto/index.ts create mode 100644 apps/api/src/widgets/dto/list-query.dto.ts create mode 100644 apps/api/src/widgets/dto/stat-card-query.dto.ts create mode 100644 apps/api/src/widgets/dto/update-widget-config.dto.ts create mode 100644 apps/api/src/widgets/widget-data.service.ts diff --git a/apps/api/src/widgets/dto/calendar-preview-query.dto.ts b/apps/api/src/widgets/dto/calendar-preview-query.dto.ts new file mode 100644 index 0000000..40fbd13 --- /dev/null +++ b/apps/api/src/widgets/dto/calendar-preview-query.dto.ts @@ -0,0 +1,34 @@ +import { + IsString, + IsOptional, + IsIn, + IsBoolean, + IsNumber, + Min, + Max, +} from "class-validator"; + +/** + * DTO for querying calendar preview widget data + */ +export class CalendarPreviewQueryDto { + @IsString({ message: "view must be a string" }) + @IsIn(["day", "week", "agenda"], { + message: "view must be one of: day, week, agenda", + }) + view!: "day" | "week" | "agenda"; + + @IsOptional() + @IsBoolean({ message: "showTasks must be a boolean" }) + showTasks?: boolean; + + @IsOptional() + @IsBoolean({ message: "showEvents must be a boolean" }) + showEvents?: boolean; + + @IsOptional() + @IsNumber({}, { message: "daysAhead must be a number" }) + @Min(1, { message: "daysAhead must be at least 1" }) + @Max(30, { message: "daysAhead must not exceed 30" }) + daysAhead?: number; +} diff --git a/apps/api/src/widgets/dto/chart-query.dto.ts b/apps/api/src/widgets/dto/chart-query.dto.ts new file mode 100644 index 0000000..a7d7114 --- /dev/null +++ b/apps/api/src/widgets/dto/chart-query.dto.ts @@ -0,0 +1,38 @@ +import { + IsString, + IsOptional, + IsIn, + IsObject, + IsArray, +} from "class-validator"; + +/** + * DTO for querying chart widget data + */ +export class ChartQueryDto { + @IsString({ message: "chartType must be a string" }) + @IsIn(["bar", "line", "pie", "donut"], { + message: "chartType must be one of: bar, line, pie, donut", + }) + chartType!: "bar" | "line" | "pie" | "donut"; + + @IsString({ message: "dataSource must be a string" }) + @IsIn(["tasks", "events", "projects"], { + message: "dataSource must be one of: tasks, events, projects", + }) + dataSource!: "tasks" | "events" | "projects"; + + @IsString({ message: "groupBy must be a string" }) + @IsIn(["status", "priority", "project", "day", "week", "month"], { + message: "groupBy must be one of: status, priority, project, day, week, month", + }) + groupBy!: "status" | "priority" | "project" | "day" | "week" | "month"; + + @IsOptional() + @IsObject({ message: "filter must be an object" }) + filter?: Record; + + @IsOptional() + @IsArray({ message: "colors must be an array" }) + colors?: string[]; +} diff --git a/apps/api/src/widgets/dto/create-widget-config.dto.ts b/apps/api/src/widgets/dto/create-widget-config.dto.ts new file mode 100644 index 0000000..e9276ba --- /dev/null +++ b/apps/api/src/widgets/dto/create-widget-config.dto.ts @@ -0,0 +1,46 @@ +import { + IsString, + IsOptional, + IsNumber, + IsObject, + MinLength, + MaxLength, + Min, + Max, +} from "class-validator"; + +/** + * DTO for creating a widget configuration in a layout + */ +export class CreateWidgetConfigDto { + @IsString({ message: "widgetType must be a string" }) + @MinLength(1, { message: "widgetType must not be empty" }) + widgetType!: string; + + @IsNumber({}, { message: "x must be a number" }) + @Min(0, { message: "x must be at least 0" }) + x!: number; + + @IsNumber({}, { message: "y must be a number" }) + @Min(0, { message: "y must be at least 0" }) + y!: number; + + @IsNumber({}, { message: "w must be a number" }) + @Min(1, { message: "w must be at least 1" }) + @Max(12, { message: "w must not exceed 12" }) + w!: number; + + @IsNumber({}, { message: "h must be a number" }) + @Min(1, { message: "h must be at least 1" }) + @Max(12, { message: "h must not exceed 12" }) + h!: number; + + @IsOptional() + @IsString({ message: "title must be a string" }) + @MaxLength(100, { message: "title must not exceed 100 characters" }) + title?: string; + + @IsOptional() + @IsObject({ message: "config must be an object" }) + config?: Record; +} diff --git a/apps/api/src/widgets/dto/index.ts b/apps/api/src/widgets/dto/index.ts new file mode 100644 index 0000000..80e917c --- /dev/null +++ b/apps/api/src/widgets/dto/index.ts @@ -0,0 +1,10 @@ +/** + * Widget DTOs + */ + +export { StatCardQueryDto } from "./stat-card-query.dto"; +export { ChartQueryDto } from "./chart-query.dto"; +export { ListQueryDto } from "./list-query.dto"; +export { CalendarPreviewQueryDto } from "./calendar-preview-query.dto"; +export { CreateWidgetConfigDto } from "./create-widget-config.dto"; +export { UpdateWidgetConfigDto } from "./update-widget-config.dto"; diff --git a/apps/api/src/widgets/dto/list-query.dto.ts b/apps/api/src/widgets/dto/list-query.dto.ts new file mode 100644 index 0000000..26fcc81 --- /dev/null +++ b/apps/api/src/widgets/dto/list-query.dto.ts @@ -0,0 +1,48 @@ +import { + IsString, + IsOptional, + IsIn, + IsObject, + IsNumber, + IsBoolean, + Min, + Max, +} from "class-validator"; + +/** + * DTO for querying list widget data + */ +export class ListQueryDto { + @IsString({ message: "dataSource must be a string" }) + @IsIn(["tasks", "events", "projects"], { + message: "dataSource must be one of: tasks, events, projects", + }) + dataSource!: "tasks" | "events" | "projects"; + + @IsOptional() + @IsString({ message: "sortBy must be a string" }) + sortBy?: string; + + @IsOptional() + @IsString({ message: "sortOrder must be a string" }) + @IsIn(["asc", "desc"], { message: "sortOrder must be asc or desc" }) + sortOrder?: "asc" | "desc"; + + @IsOptional() + @IsNumber({}, { message: "limit must be a number" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(50, { message: "limit must not exceed 50" }) + limit?: number; + + @IsOptional() + @IsObject({ message: "filter must be an object" }) + filter?: Record; + + @IsOptional() + @IsBoolean({ message: "showStatus must be a boolean" }) + showStatus?: boolean; + + @IsOptional() + @IsBoolean({ message: "showDueDate must be a boolean" }) + showDueDate?: boolean; +} diff --git a/apps/api/src/widgets/dto/stat-card-query.dto.ts b/apps/api/src/widgets/dto/stat-card-query.dto.ts new file mode 100644 index 0000000..bf13b45 --- /dev/null +++ b/apps/api/src/widgets/dto/stat-card-query.dto.ts @@ -0,0 +1,27 @@ +import { + IsString, + IsOptional, + IsIn, + IsObject, +} from "class-validator"; + +/** + * DTO for querying stat card widget data + */ +export class StatCardQueryDto { + @IsString({ message: "dataSource must be a string" }) + @IsIn(["tasks", "events", "projects"], { + message: "dataSource must be one of: tasks, events, projects", + }) + dataSource!: "tasks" | "events" | "projects"; + + @IsString({ message: "metric must be a string" }) + @IsIn(["count", "completed", "overdue", "upcoming"], { + message: "metric must be one of: count, completed, overdue, upcoming", + }) + metric!: "count" | "completed" | "overdue" | "upcoming"; + + @IsOptional() + @IsObject({ message: "filter must be an object" }) + filter?: Record; +} diff --git a/apps/api/src/widgets/dto/update-widget-config.dto.ts b/apps/api/src/widgets/dto/update-widget-config.dto.ts new file mode 100644 index 0000000..e5360a3 --- /dev/null +++ b/apps/api/src/widgets/dto/update-widget-config.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from "@nestjs/mapped-types"; +import { CreateWidgetConfigDto } from "./create-widget-config.dto"; + +/** + * DTO for updating a widget configuration + */ +export class UpdateWidgetConfigDto extends PartialType(CreateWidgetConfigDto) {} diff --git a/apps/api/src/widgets/widget-data.service.ts b/apps/api/src/widgets/widget-data.service.ts new file mode 100644 index 0000000..e772bff --- /dev/null +++ b/apps/api/src/widgets/widget-data.service.ts @@ -0,0 +1,571 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { TaskStatus, TaskPriority, ProjectStatus } from "@prisma/client"; +import type { + StatCardQueryDto, + ChartQueryDto, + ListQueryDto, + CalendarPreviewQueryDto, +} from "./dto"; + +/** + * Widget data response types + */ +export interface WidgetStatData { + value: number; + change?: number; + changePercent?: number; + previousValue?: number; +} + +export interface WidgetChartData { + labels: string[]; + datasets: { + label: string; + data: number[]; + backgroundColor?: string[]; + }[]; +} + +export interface WidgetListItem { + id: string; + title: string; + subtitle?: string; + status?: string; + priority?: string; + dueDate?: string; + startTime?: string; + color?: string; +} + +export interface WidgetCalendarItem { + id: string; + title: string; + startTime: string; + endTime?: string; + allDay?: boolean; + type: "task" | "event"; + color?: string; +} + +/** + * Service for fetching widget data from various sources + */ +@Injectable() +export class WidgetDataService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Get stat card data based on configuration + */ + async getStatCardData( + workspaceId: string, + query: StatCardQueryDto + ): Promise { + const { dataSource, metric, filter } = query; + + switch (dataSource) { + case "tasks": + return this.getTaskStatData(workspaceId, metric, filter); + case "events": + return this.getEventStatData(workspaceId, metric, filter); + case "projects": + return this.getProjectStatData(workspaceId, metric, filter); + default: + return { value: 0 }; + } + } + + /** + * Get chart data based on configuration + */ + async getChartData( + workspaceId: string, + query: ChartQueryDto + ): Promise { + const { dataSource, groupBy, filter, colors } = query; + + switch (dataSource) { + case "tasks": + return this.getTaskChartData(workspaceId, groupBy, filter, colors); + case "events": + return this.getEventChartData(workspaceId, groupBy, filter, colors); + case "projects": + return this.getProjectChartData(workspaceId, groupBy, filter, colors); + default: + return { labels: [], datasets: [] }; + } + } + + /** + * Get list data based on configuration + */ + async getListData( + workspaceId: string, + query: ListQueryDto + ): Promise { + const { dataSource, sortBy, sortOrder, limit, filter } = query; + + switch (dataSource) { + case "tasks": + return this.getTaskListData(workspaceId, sortBy, sortOrder, limit, filter); + case "events": + return this.getEventListData(workspaceId, sortBy, sortOrder, limit, filter); + case "projects": + return this.getProjectListData(workspaceId, sortBy, sortOrder, limit, filter); + default: + return []; + } + } + + /** + * Get calendar preview data + */ + async getCalendarPreviewData( + workspaceId: string, + query: CalendarPreviewQueryDto + ): Promise { + const { showTasks = true, showEvents = true, daysAhead = 7 } = query; + const items: WidgetCalendarItem[] = []; + + const startDate = new Date(); + startDate.setHours(0, 0, 0, 0); + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + daysAhead); + + if (showEvents) { + const events = await this.prisma.event.findMany({ + where: { + workspaceId, + startTime: { + gte: startDate, + lte: endDate, + }, + }, + include: { + project: { + select: { color: true }, + }, + }, + orderBy: { startTime: "asc" }, + take: 20, + }); + + items.push( + ...events.map((event) => ({ + id: event.id, + title: event.title, + startTime: event.startTime.toISOString(), + endTime: event.endTime?.toISOString(), + allDay: event.allDay, + type: "event" as const, + color: event.project?.color || "#3B82F6", + })) + ); + } + + if (showTasks) { + const tasks = await this.prisma.task.findMany({ + where: { + workspaceId, + dueDate: { + gte: startDate, + lte: endDate, + }, + status: { + not: TaskStatus.COMPLETED, + }, + }, + include: { + project: { + select: { color: true }, + }, + }, + orderBy: { dueDate: "asc" }, + take: 20, + }); + + items.push( + ...tasks.map((task) => ({ + id: task.id, + title: task.title, + startTime: task.dueDate!.toISOString(), + allDay: true, + type: "task" as const, + color: task.project?.color || "#10B981", + })) + ); + } + + // Sort by start time + items.sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + ); + + return items; + } + + // Private helper methods + + private async getTaskStatData( + workspaceId: string, + metric: string, + filter?: Record + ): Promise { + const where: Record = { workspaceId, ...filter }; + + switch (metric) { + case "count": + const count = await this.prisma.task.count({ where }); + return { value: count }; + + case "completed": + const completed = await this.prisma.task.count({ + where: { ...where, status: TaskStatus.COMPLETED }, + }); + return { value: completed }; + + case "overdue": + const overdue = await this.prisma.task.count({ + where: { + ...where, + status: { not: TaskStatus.COMPLETED }, + dueDate: { lt: new Date() }, + }, + }); + return { value: overdue }; + + case "upcoming": + const nextWeek = new Date(); + nextWeek.setDate(nextWeek.getDate() + 7); + const upcoming = await this.prisma.task.count({ + where: { + ...where, + status: { not: TaskStatus.COMPLETED }, + dueDate: { gte: new Date(), lte: nextWeek }, + }, + }); + return { value: upcoming }; + + default: + return { value: 0 }; + } + } + + private async getEventStatData( + workspaceId: string, + metric: string, + filter?: Record + ): Promise { + const where: Record = { workspaceId, ...filter }; + + switch (metric) { + case "count": + const count = await this.prisma.event.count({ where }); + return { value: count }; + + case "upcoming": + const nextWeek = new Date(); + nextWeek.setDate(nextWeek.getDate() + 7); + const upcoming = await this.prisma.event.count({ + where: { + ...where, + startTime: { gte: new Date(), lte: nextWeek }, + }, + }); + return { value: upcoming }; + + default: + return { value: 0 }; + } + } + + private async getProjectStatData( + workspaceId: string, + metric: string, + filter?: Record + ): Promise { + const where: Record = { workspaceId, ...filter }; + + switch (metric) { + case "count": + const count = await this.prisma.project.count({ where }); + return { value: count }; + + case "completed": + const completed = await this.prisma.project.count({ + where: { ...where, status: ProjectStatus.COMPLETED }, + }); + return { value: completed }; + + default: + return { value: 0 }; + } + } + + private async getTaskChartData( + workspaceId: string, + groupBy: string, + filter?: Record, + colors?: string[] + ): Promise { + const where: Record = { workspaceId, ...filter }; + const defaultColors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"]; + + switch (groupBy) { + case "status": + const statusCounts = await this.prisma.task.groupBy({ + by: ["status"], + where, + _count: { id: true }, + }); + + const statusLabels = Object.values(TaskStatus); + const statusData = statusLabels.map((status) => { + const found = statusCounts.find((s) => s.status === status); + return found ? found._count.id : 0; + }); + + return { + labels: statusLabels.map((s) => s.replace("_", " ")), + datasets: [ + { + label: "Tasks by Status", + data: statusData, + backgroundColor: colors || defaultColors, + }, + ], + }; + + case "priority": + const priorityCounts = await this.prisma.task.groupBy({ + by: ["priority"], + where, + _count: { id: true }, + }); + + const priorityLabels = Object.values(TaskPriority); + const priorityData = priorityLabels.map((priority) => { + const found = priorityCounts.find((p) => p.priority === priority); + return found ? found._count.id : 0; + }); + + return { + labels: priorityLabels, + datasets: [ + { + label: "Tasks by Priority", + data: priorityData, + backgroundColor: colors || ["#EF4444", "#F59E0B", "#3B82F6", "#10B981"], + }, + ], + }; + + case "project": + const projectCounts = await this.prisma.task.groupBy({ + by: ["projectId"], + where: { ...where, projectId: { not: null } }, + _count: { id: true }, + }); + + const projectIds = projectCounts.map((p) => p.projectId!); + const projects = await this.prisma.project.findMany({ + where: { id: { in: projectIds } }, + select: { id: true, name: true, color: true }, + }); + + return { + labels: projects.map((p) => p.name), + datasets: [ + { + label: "Tasks by Project", + data: projectCounts.map((p) => p._count.id), + backgroundColor: projects.map((p) => p.color || "#3B82F6"), + }, + ], + }; + + default: + return { labels: [], datasets: [] }; + } + } + + private async getEventChartData( + workspaceId: string, + groupBy: string, + filter?: Record, + colors?: string[] + ): Promise { + const where: Record = { workspaceId, ...filter }; + const defaultColors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"]; + + switch (groupBy) { + case "project": + const projectCounts = await this.prisma.event.groupBy({ + by: ["projectId"], + where: { ...where, projectId: { not: null } }, + _count: { id: true }, + }); + + const projectIds = projectCounts.map((p) => p.projectId!); + const projects = await this.prisma.project.findMany({ + where: { id: { in: projectIds } }, + select: { id: true, name: true, color: true }, + }); + + return { + labels: projects.map((p) => p.name), + datasets: [ + { + label: "Events by Project", + data: projectCounts.map((p) => p._count.id), + backgroundColor: projects.map((p) => p.color || "#3B82F6"), + }, + ], + }; + + default: + return { labels: [], datasets: [{ label: "Events", data: [], backgroundColor: colors || defaultColors }] }; + } + } + + private async getProjectChartData( + workspaceId: string, + groupBy: string, + filter?: Record, + colors?: string[] + ): Promise { + const where: Record = { workspaceId, ...filter }; + const defaultColors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"]; + + switch (groupBy) { + case "status": + const statusCounts = await this.prisma.project.groupBy({ + by: ["status"], + where, + _count: { id: true }, + }); + + const statusLabels = Object.values(ProjectStatus); + const statusData = statusLabels.map((status) => { + const found = statusCounts.find((s) => s.status === status); + return found ? found._count.id : 0; + }); + + return { + labels: statusLabels.map((s) => s.replace("_", " ")), + datasets: [ + { + label: "Projects by Status", + data: statusData, + backgroundColor: colors || defaultColors, + }, + ], + }; + + default: + return { labels: [], datasets: [] }; + } + } + + private async getTaskListData( + workspaceId: string, + sortBy?: string, + sortOrder?: "asc" | "desc", + limit?: number, + filter?: Record + ): Promise { + const where: Record = { workspaceId, ...filter }; + const orderBy: Record = {}; + + if (sortBy) { + orderBy[sortBy] = sortOrder || "desc"; + } else { + orderBy.createdAt = "desc"; + } + + const tasks = await this.prisma.task.findMany({ + where, + include: { + project: { select: { name: true, color: true } }, + }, + orderBy, + take: limit || 10, + }); + + return tasks.map((task) => ({ + id: task.id, + title: task.title, + subtitle: task.project?.name, + status: task.status, + priority: task.priority, + dueDate: task.dueDate?.toISOString(), + color: task.project?.color || undefined, + })); + } + + private async getEventListData( + workspaceId: string, + sortBy?: string, + sortOrder?: "asc" | "desc", + limit?: number, + filter?: Record + ): Promise { + const where: Record = { workspaceId, ...filter }; + const orderBy: Record = {}; + + if (sortBy) { + orderBy[sortBy] = sortOrder || "asc"; + } else { + orderBy.startTime = "asc"; + } + + const events = await this.prisma.event.findMany({ + where, + include: { + project: { select: { name: true, color: true } }, + }, + orderBy, + take: limit || 10, + }); + + return events.map((event) => ({ + id: event.id, + title: event.title, + subtitle: event.project?.name, + startTime: event.startTime.toISOString(), + color: event.project?.color || undefined, + })); + } + + private async getProjectListData( + workspaceId: string, + sortBy?: string, + sortOrder?: "asc" | "desc", + limit?: number, + filter?: Record + ): Promise { + const where: Record = { workspaceId, ...filter }; + const orderBy: Record = {}; + + if (sortBy) { + orderBy[sortBy] = sortOrder || "desc"; + } else { + orderBy.createdAt = "desc"; + } + + const projects = await this.prisma.project.findMany({ + where, + orderBy, + take: limit || 10, + }); + + return projects.map((project) => ({ + id: project.id, + title: project.name, + subtitle: project.description || undefined, + status: project.status, + color: project.color || undefined, + })); + } +} diff --git a/apps/api/src/widgets/widgets.controller.ts b/apps/api/src/widgets/widgets.controller.ts index 2c4a3fc..47b8481 100644 --- a/apps/api/src/widgets/widgets.controller.ts +++ b/apps/api/src/widgets/widgets.controller.ts @@ -1,21 +1,33 @@ import { Controller, Get, + Post, + Body, Param, UseGuards, + Request, } from "@nestjs/common"; import { WidgetsService } from "./widgets.service"; +import { WidgetDataService } from "./widget-data.service"; import { AuthGuard } from "../auth/guards/auth.guard"; +import type { + StatCardQueryDto, + ChartQueryDto, + ListQueryDto, + CalendarPreviewQueryDto, +} from "./dto"; /** - * Controller for widget definition endpoints + * Controller for widget definition and data endpoints * All endpoints require authentication - * Provides read-only access to available widget definitions */ @Controller("widgets") @UseGuards(AuthGuard) export class WidgetsController { - constructor(private readonly widgetsService: WidgetsService) {} + constructor( + private readonly widgetsService: WidgetsService, + private readonly widgetDataService: WidgetDataService + ) {} /** * GET /api/widgets @@ -36,4 +48,47 @@ export class WidgetsController { async findByName(@Param("name") name: string) { return this.widgetsService.findByName(name); } + + /** + * POST /api/widgets/data/stat-card + * Get stat card widget data + */ + @Post("data/stat-card") + async getStatCardData(@Request() req: any, @Body() query: StatCardQueryDto) { + const workspaceId = req.user?.currentWorkspaceId || req.user?.workspaceId; + return this.widgetDataService.getStatCardData(workspaceId, query); + } + + /** + * POST /api/widgets/data/chart + * Get chart widget data + */ + @Post("data/chart") + async getChartData(@Request() req: any, @Body() query: ChartQueryDto) { + const workspaceId = req.user?.currentWorkspaceId || req.user?.workspaceId; + return this.widgetDataService.getChartData(workspaceId, query); + } + + /** + * POST /api/widgets/data/list + * Get list widget data + */ + @Post("data/list") + async getListData(@Request() req: any, @Body() query: ListQueryDto) { + const workspaceId = req.user?.currentWorkspaceId || req.user?.workspaceId; + return this.widgetDataService.getListData(workspaceId, query); + } + + /** + * POST /api/widgets/data/calendar-preview + * Get calendar preview widget data + */ + @Post("data/calendar-preview") + async getCalendarPreviewData( + @Request() req: any, + @Body() query: CalendarPreviewQueryDto + ) { + const workspaceId = req.user?.currentWorkspaceId || req.user?.workspaceId; + return this.widgetDataService.getCalendarPreviewData(workspaceId, query); + } } diff --git a/apps/api/src/widgets/widgets.module.ts b/apps/api/src/widgets/widgets.module.ts index 64b20cb..82156b7 100644 --- a/apps/api/src/widgets/widgets.module.ts +++ b/apps/api/src/widgets/widgets.module.ts @@ -1,13 +1,14 @@ import { Module } from "@nestjs/common"; import { WidgetsController } from "./widgets.controller"; import { WidgetsService } from "./widgets.service"; +import { WidgetDataService } from "./widget-data.service"; import { PrismaModule } from "../prisma/prisma.module"; import { AuthModule } from "../auth/auth.module"; @Module({ imports: [PrismaModule, AuthModule], controllers: [WidgetsController], - providers: [WidgetsService], - exports: [WidgetsService], + providers: [WidgetsService, WidgetDataService], + exports: [WidgetsService, WidgetDataService], }) export class WidgetsModule {} diff --git a/packages/shared/src/types/widget.types.ts b/packages/shared/src/types/widget.types.ts index 48f38f8..20e6743 100644 --- a/packages/shared/src/types/widget.types.ts +++ b/packages/shared/src/types/widget.types.ts @@ -68,7 +68,11 @@ export type WidgetComponentType = | "TasksWidget" | "CalendarWidget" | "QuickCaptureWidget" - | "AgentStatusWidget"; + | "AgentStatusWidget" + | "StatCardWidget" + | "ChartWidget" + | "ListWidget" + | "CalendarPreviewWidget"; /** * Props for individual widgets @@ -79,3 +83,83 @@ export interface WidgetProps { onEdit?: () => void; onRemove?: () => void; } + +/** + * Widget configuration types + */ +export interface StatCardConfig { + title?: string; + dataSource: "tasks" | "events" | "projects"; + metric: "count" | "completed" | "overdue" | "upcoming"; + filter?: Record; + color?: string; + icon?: string; +} + +export interface ChartConfig { + title?: string; + chartType: "bar" | "line" | "pie" | "donut"; + dataSource: "tasks" | "events" | "projects"; + groupBy: "status" | "priority" | "project" | "day" | "week" | "month"; + filter?: Record; + colors?: string[]; +} + +export interface ListConfig { + title?: string; + dataSource: "tasks" | "events" | "projects"; + sortBy?: string; + sortOrder?: "asc" | "desc"; + limit?: number; + filter?: Record; + showStatus?: boolean; + showDueDate?: boolean; +} + +export interface CalendarPreviewConfig { + title?: string; + view: "day" | "week" | "agenda"; + showTasks?: boolean; + showEvents?: boolean; + daysAhead?: number; +} + +/** + * Widget data response types + */ +export interface WidgetStatData { + value: number; + change?: number; + changePercent?: number; + previousValue?: number; +} + +export interface WidgetChartData { + labels: string[]; + datasets: { + label: string; + data: number[]; + backgroundColor?: string[]; + }[]; +} + +export interface WidgetListItem { + id: string; + title: string; + subtitle?: string; + status?: string; + priority?: string; + dueDate?: string; + startTime?: string; + color?: string; +} + +export interface WidgetCalendarItem { + id: string; + title: string; + startTime: string; + endTime?: string; + allDay?: boolean; + type: "task" | "event"; + color?: string; +}