Files
stack/apps/api/src/widgets/widget-data.service.ts
Jason Woltje 4c3604e85c
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
feat(#52): implement Active Projects & Agent Chains widget
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>
2026-02-03 19:17:13 -06:00

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,
}));
}
}