Add HUD widget for tracking active projects and running agent sessions. Backend: - Add getActiveProjectsData() and getAgentChainsData() to WidgetDataService - Create POST /api/widgets/data/active-projects endpoint - Create POST /api/widgets/data/agent-chains endpoint - Add WidgetProjectItem and WidgetAgentSessionItem response types Frontend: - Create ActiveProjectsWidget component with dual panels - Active Projects panel: name, color, task/event counts, last activity - Agent Chains panel: status, runtime, message count, expandable details - Real-time updates (projects: 30s, agents: 10s) - PDA-friendly status indicators (Running vs URGENT) Testing: - 7 comprehensive tests covering loading, rendering, empty states, expandability - All tests passing (7/7) Refs #52 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
696 lines
18 KiB
TypeScript
696 lines
18 KiB
TypeScript
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;
|
|
}
|
|
|
|
export interface WidgetProjectItem {
|
|
id: string;
|
|
name: string;
|
|
status: string;
|
|
lastActivity: string;
|
|
taskCount: number;
|
|
eventCount: number;
|
|
color: string | null;
|
|
}
|
|
|
|
export interface WidgetAgentSessionItem {
|
|
id: string;
|
|
sessionKey: string;
|
|
label: string | null;
|
|
channel: string | null;
|
|
agentName: string | null;
|
|
agentStatus: string | null;
|
|
status: "active" | "ended";
|
|
startedAt: string;
|
|
lastMessageAt: string | null;
|
|
runtimeMs: number;
|
|
messageCount: number;
|
|
contextSummary: string | null;
|
|
}
|
|
|
|
/**
|
|
* 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<WidgetStatData> {
|
|
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<WidgetChartData> {
|
|
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<WidgetListItem[]> {
|
|
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<WidgetCalendarItem[]> {
|
|
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) => {
|
|
const item: WidgetCalendarItem = {
|
|
id: event.id,
|
|
title: event.title,
|
|
startTime: event.startTime.toISOString(),
|
|
allDay: event.allDay,
|
|
type: "event" as const,
|
|
color: event.project?.color ?? "#3B82F6",
|
|
};
|
|
if (event.endTime !== null) {
|
|
item.endTime = event.endTime.toISOString();
|
|
}
|
|
return item;
|
|
})
|
|
);
|
|
}
|
|
|
|
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
|
|
.filter((task): task is typeof task & { dueDate: Date } => task.dueDate !== null)
|
|
.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<string, unknown>
|
|
): Promise<WidgetStatData> {
|
|
const where: Record<string, unknown> = { 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<string, unknown>
|
|
): Promise<WidgetStatData> {
|
|
const where: Record<string, unknown> = { 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<string, unknown>
|
|
): Promise<WidgetStatData> {
|
|
const where: Record<string, unknown> = { 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<string, unknown>,
|
|
colors?: string[]
|
|
): Promise<WidgetChartData> {
|
|
const where: Record<string, unknown> = { 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) => {
|
|
if (p.projectId === null) {
|
|
throw new Error("Unexpected null projectId");
|
|
}
|
|
return 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<string, unknown>,
|
|
colors?: string[]
|
|
): Promise<WidgetChartData> {
|
|
const where: Record<string, unknown> = { 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) => {
|
|
if (p.projectId === null) {
|
|
throw new Error("Unexpected null projectId");
|
|
}
|
|
return 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<string, unknown>,
|
|
colors?: string[]
|
|
): Promise<WidgetChartData> {
|
|
const where: Record<string, unknown> = { 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<string, unknown>
|
|
): Promise<WidgetListItem[]> {
|
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
|
const orderBy: Record<string, "asc" | "desc"> = {};
|
|
|
|
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) => {
|
|
const item: WidgetListItem = {
|
|
id: task.id,
|
|
title: task.title,
|
|
status: task.status,
|
|
priority: task.priority,
|
|
};
|
|
if (task.project?.name) {
|
|
item.subtitle = task.project.name;
|
|
}
|
|
if (task.dueDate) {
|
|
item.dueDate = task.dueDate.toISOString();
|
|
}
|
|
if (task.project?.color) {
|
|
item.color = task.project.color;
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
|
|
private async getEventListData(
|
|
workspaceId: string,
|
|
sortBy?: string,
|
|
sortOrder?: "asc" | "desc",
|
|
limit?: number,
|
|
filter?: Record<string, unknown>
|
|
): Promise<WidgetListItem[]> {
|
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
|
const orderBy: Record<string, "asc" | "desc"> = {};
|
|
|
|
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) => {
|
|
const item: WidgetListItem = {
|
|
id: event.id,
|
|
title: event.title,
|
|
startTime: event.startTime.toISOString(),
|
|
};
|
|
if (event.project?.name) {
|
|
item.subtitle = event.project.name;
|
|
}
|
|
if (event.project?.color) {
|
|
item.color = event.project.color;
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
|
|
private async getProjectListData(
|
|
workspaceId: string,
|
|
sortBy?: string,
|
|
sortOrder?: "asc" | "desc",
|
|
limit?: number,
|
|
filter?: Record<string, unknown>
|
|
): Promise<WidgetListItem[]> {
|
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
|
const orderBy: Record<string, "asc" | "desc"> = {};
|
|
|
|
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) => {
|
|
const item: WidgetListItem = {
|
|
id: project.id,
|
|
title: project.name,
|
|
status: project.status,
|
|
};
|
|
if (project.description) {
|
|
item.subtitle = project.description;
|
|
}
|
|
if (project.color) {
|
|
item.color = project.color;
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get active projects data
|
|
*/
|
|
async getActiveProjectsData(workspaceId: string): Promise<WidgetProjectItem[]> {
|
|
const projects = await this.prisma.project.findMany({
|
|
where: {
|
|
workspaceId,
|
|
status: ProjectStatus.ACTIVE,
|
|
},
|
|
include: {
|
|
_count: {
|
|
select: { tasks: true, events: true },
|
|
},
|
|
},
|
|
orderBy: {
|
|
updatedAt: "desc",
|
|
},
|
|
take: 20,
|
|
});
|
|
|
|
return projects.map((project) => ({
|
|
id: project.id,
|
|
name: project.name,
|
|
status: project.status,
|
|
lastActivity: project.updatedAt.toISOString(),
|
|
taskCount: project._count.tasks,
|
|
eventCount: project._count.events,
|
|
color: project.color,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get agent chains data (active agent sessions)
|
|
*/
|
|
async getAgentChainsData(workspaceId: string): Promise<WidgetAgentSessionItem[]> {
|
|
const sessions = await this.prisma.agentSession.findMany({
|
|
where: {
|
|
workspaceId,
|
|
isActive: true,
|
|
},
|
|
include: {
|
|
agent: {
|
|
select: {
|
|
name: true,
|
|
status: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
startedAt: "desc",
|
|
},
|
|
take: 20,
|
|
});
|
|
|
|
const now = new Date();
|
|
|
|
return sessions.map((session) => ({
|
|
id: session.id,
|
|
sessionKey: session.sessionKey,
|
|
label: session.label,
|
|
channel: session.channel,
|
|
agentName: session.agent?.name ?? null,
|
|
agentStatus: session.agent?.status ?? null,
|
|
status: session.isActive ? ("active" as const) : ("ended" as const),
|
|
startedAt: session.startedAt.toISOString(),
|
|
lastMessageAt: session.lastMessageAt ? session.lastMessageAt.toISOString() : null,
|
|
runtimeMs: now.getTime() - session.startedAt.getTime(),
|
|
messageCount: session.messageCount,
|
|
contextSummary: session.contextSummary,
|
|
}));
|
|
}
|
|
}
|