feat: add domains, ideas, layouts, widgets API modules

- Add DomainsModule with full CRUD, search, and activity logging
- Add IdeasModule with quick capture endpoint
- Add LayoutsModule for user dashboard layouts
- Add WidgetsModule for widget definitions (read-only)
- Update ActivityService with domain/idea logging methods
- Register all new modules in AppModule
This commit is contained in:
Jason Woltje
2026-01-29 13:47:03 -06:00
parent 973502f26e
commit f47dd8bc92
66 changed files with 4277 additions and 29 deletions

View File

@@ -534,6 +534,9 @@ model UserLayout {
// Layout configuration (array of widget placements)
layout Json @default("[]")
// Additional metadata for the layout
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityAction, EntityType } from "@prisma/client";
import { ActivityAction, EntityType, Prisma } from "@prisma/client";
import type {
CreateActivityLogInput,
PaginatedActivityLogs,
@@ -23,7 +23,7 @@ export class ActivityService {
async logActivity(input: CreateActivityLogInput) {
try {
return await this.prisma.activityLog.create({
data: input,
data: input as unknown as Prisma.ActivityLogCreateInput,
});
} catch (error) {
this.logger.error("Failed to log activity", error);
@@ -167,7 +167,7 @@ export class ActivityService {
workspaceId: string,
userId: string,
taskId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -186,7 +186,7 @@ export class ActivityService {
workspaceId: string,
userId: string,
taskId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -205,7 +205,7 @@ export class ActivityService {
workspaceId: string,
userId: string,
taskId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -224,7 +224,7 @@ export class ActivityService {
workspaceId: string,
userId: string,
taskId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -262,7 +262,7 @@ export class ActivityService {
workspaceId: string,
userId: string,
eventId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -281,7 +281,7 @@ export class ActivityService {
workspaceId: string,
userId: string,
eventId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -300,7 +300,7 @@ export class ActivityService {
workspaceId: string,
userId: string,
eventId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -319,7 +319,7 @@ export class ActivityService {
workspaceId: string,
userId: string,
projectId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -338,7 +338,7 @@ export class ActivityService {
workspaceId: string,
userId: string,
projectId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -357,7 +357,7 @@ export class ActivityService {
workspaceId: string,
userId: string,
projectId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -375,7 +375,7 @@ export class ActivityService {
async logWorkspaceCreated(
workspaceId: string,
userId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -393,7 +393,7 @@ export class ActivityService {
async logWorkspaceUpdated(
workspaceId: string,
userId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -448,7 +448,7 @@ export class ActivityService {
async logUserUpdated(
workspaceId: string,
userId: string,
details?: Record<string, any>
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
@@ -459,4 +459,118 @@ export class ActivityService {
...(details && { details }),
});
}
/**
* Log domain creation
*/
async logDomainCreated(
workspaceId: string,
userId: string,
domainId: string,
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.DOMAIN,
entityId: domainId,
...(details && { details }),
});
}
/**
* Log domain update
*/
async logDomainUpdated(
workspaceId: string,
userId: string,
domainId: string,
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.DOMAIN,
entityId: domainId,
...(details && { details }),
});
}
/**
* Log domain deletion
*/
async logDomainDeleted(
workspaceId: string,
userId: string,
domainId: string,
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.DOMAIN,
entityId: domainId,
...(details && { details }),
});
}
/**
* Log idea creation
*/
async logIdeaCreated(
workspaceId: string,
userId: string,
ideaId: string,
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.IDEA,
entityId: ideaId,
...(details && { details }),
});
}
/**
* Log idea update
*/
async logIdeaUpdated(
workspaceId: string,
userId: string,
ideaId: string,
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.IDEA,
entityId: ideaId,
...(details && { details }),
});
}
/**
* Log idea deletion
*/
async logIdeaDeleted(
workspaceId: string,
userId: string,
ideaId: string,
details?: Prisma.JsonValue
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.IDEA,
entityId: ideaId,
...(details && { details }),
});
}
}

View File

@@ -9,7 +9,7 @@ export interface CreateActivityLogInput {
action: ActivityAction;
entityType: EntityType;
entityId: string;
details?: Record<string, any>;
details?: Prisma.JsonValue;
ipAddress?: string;
userAgent?: string;
}

View File

@@ -8,6 +8,10 @@ import { ActivityModule } from "./activity/activity.module";
import { TasksModule } from "./tasks/tasks.module";
import { EventsModule } from "./events/events.module";
import { ProjectsModule } from "./projects/projects.module";
import { DomainsModule } from "./domains/domains.module";
import { IdeasModule } from "./ideas/ideas.module";
import { WidgetsModule } from "./widgets/widgets.module";
import { LayoutsModule } from "./layouts/layouts.module";
@Module({
imports: [
@@ -18,6 +22,10 @@ import { ProjectsModule } from "./projects/projects.module";
TasksModule,
EventsModule,
ProjectsModule,
DomainsModule,
IdeasModule,
WidgetsModule,
LayoutsModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -0,0 +1,104 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
UnauthorizedException,
} from "@nestjs/common";
import { DomainsService } from "./domains.service";
import { CreateDomainDto, UpdateDomainDto, QueryDomainsDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
/**
* Controller for domain endpoints
* All endpoints require authentication
*/
@Controller("domains")
@UseGuards(AuthGuard)
export class DomainsController {
constructor(private readonly domainsService: DomainsService) {}
/**
* POST /api/domains
* Create a new domain
*/
@Post()
async create(@Body() createDomainDto: CreateDomainDto, @Request() req: any) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.domainsService.create(workspaceId, userId, createDomainDto);
}
/**
* GET /api/domains
* Get paginated domains with optional filters
*/
@Get()
async findAll(@Query() query: QueryDomainsDto, @Request() req: any) {
const workspaceId = req.user?.workspaceId;
if (!workspaceId) {
throw new UnauthorizedException("Authentication required");
}
return this.domainsService.findAll({ ...query, workspaceId });
}
/**
* GET /api/domains/:id
* Get a single domain by ID
*/
@Get(":id")
async findOne(@Param("id") id: string, @Request() req: any) {
const workspaceId = req.user?.workspaceId;
if (!workspaceId) {
throw new UnauthorizedException("Authentication required");
}
return this.domainsService.findOne(id, workspaceId);
}
/**
* PATCH /api/domains/:id
* Update a domain
*/
@Patch(":id")
async update(
@Param("id") id: string,
@Body() updateDomainDto: UpdateDomainDto,
@Request() req: any
) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.domainsService.update(id, workspaceId, userId, updateDomainDto);
}
/**
* DELETE /api/domains/:id
* Delete a domain
*/
@Delete(":id")
async remove(@Param("id") id: string, @Request() req: any) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.domainsService.remove(id, workspaceId, userId);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { DomainsController } from "./domains.controller";
import { DomainsService } from "./domains.service";
import { PrismaModule } from "../prisma/prisma.module";
import { ActivityModule } from "../activity/activity.module";
import { AuthModule } from "../auth/auth.module";
@Module({
imports: [PrismaModule, ActivityModule, AuthModule],
controllers: [DomainsController],
providers: [DomainsService],
exports: [DomainsService],
})
export class DomainsModule {}

View File

@@ -0,0 +1,199 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityService } from "../activity/activity.service";
import type { CreateDomainDto, UpdateDomainDto, QueryDomainsDto } from "./dto";
/**
* Service for managing domains
*/
@Injectable()
export class DomainsService {
constructor(
private readonly prisma: PrismaService,
private readonly activityService: ActivityService
) {}
/**
* Create a new domain
*/
async create(
workspaceId: string,
userId: string,
createDomainDto: CreateDomainDto
) {
const domain = await this.prisma.domain.create({
data: {
...createDomainDto,
workspaceId,
metadata: (createDomainDto.metadata || {}) as unknown as Prisma.InputJsonValue,
sortOrder: 0, // Default to 0, consistent with other services
},
include: {
_count: {
select: { tasks: true, events: true, projects: true, ideas: true },
},
},
});
// Log activity
await this.activityService.logDomainCreated(
workspaceId,
userId,
domain.id,
{
name: domain.name,
}
);
return domain;
}
/**
* Get paginated domains with filters
*/
async findAll(query: QueryDomainsDto) {
const page = query.page || 1;
const limit = query.limit || 50;
const skip = (page - 1) * limit;
// Build where clause
const where: any = {
workspaceId: query.workspaceId,
};
// Add search filter if provided
if (query.search) {
where.OR = [
{ name: { contains: query.search, mode: "insensitive" } },
{ description: { contains: query.search, mode: "insensitive" } },
];
}
// Execute queries in parallel
const [data, total] = await Promise.all([
this.prisma.domain.findMany({
where,
include: {
_count: {
select: { tasks: true, events: true, projects: true, ideas: true },
},
},
orderBy: {
sortOrder: "asc",
},
skip,
take: limit,
}),
this.prisma.domain.count({ where }),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Get a single domain by ID
*/
async findOne(id: string, workspaceId: string) {
const domain = await this.prisma.domain.findUnique({
where: {
id,
workspaceId,
},
include: {
_count: {
select: { tasks: true, events: true, projects: true, ideas: true },
},
},
});
if (!domain) {
throw new NotFoundException(`Domain with ID ${id} not found`);
}
return domain;
}
/**
* Update a domain
*/
async update(
id: string,
workspaceId: string,
userId: string,
updateDomainDto: UpdateDomainDto
) {
// Verify domain exists
const existingDomain = await this.prisma.domain.findUnique({
where: { id, workspaceId },
});
if (!existingDomain) {
throw new NotFoundException(`Domain with ID ${id} not found`);
}
const domain = await this.prisma.domain.update({
where: {
id,
workspaceId,
},
data: updateDomainDto as any,
include: {
_count: {
select: { tasks: true, events: true, projects: true, ideas: true },
},
},
});
// Log activity
await this.activityService.logDomainUpdated(
workspaceId,
userId,
id,
{
changes: updateDomainDto as Prisma.JsonValue,
}
);
return domain;
}
/**
* Delete a domain
*/
async remove(id: string, workspaceId: string, userId: string) {
// Verify domain exists
const domain = await this.prisma.domain.findUnique({
where: { id, workspaceId },
});
if (!domain) {
throw new NotFoundException(`Domain with ID ${id} not found`);
}
await this.prisma.domain.delete({
where: {
id,
workspaceId,
},
});
// Log activity
await this.activityService.logDomainDeleted(
workspaceId,
userId,
id,
{
name: domain.name,
}
);
}
}

View File

@@ -0,0 +1,47 @@
import {
IsString,
IsOptional,
MinLength,
MaxLength,
Matches,
IsObject,
} from "class-validator";
/**
* DTO for creating a new domain
*/
export class CreateDomainDto {
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name!: string;
@IsString({ message: "slug must be a string" })
@MinLength(1, { message: "slug must not be empty" })
@MaxLength(100, { message: "slug must not exceed 100 characters" })
@Matches(/^[a-z0-9-]+$/, {
message: "slug must contain only lowercase letters, numbers, and hyphens",
})
slug!: string;
@IsOptional()
@IsString({ message: "description must be a string" })
@MaxLength(10000, { message: "description must not exceed 10000 characters" })
description?: string;
@IsOptional()
@IsString({ message: "color must be a string" })
@Matches(/^#[0-9A-F]{6}$/i, {
message: "color must be a valid hex color code (e.g., #FF5733)",
})
color?: string;
@IsOptional()
@IsString({ message: "icon must be a string" })
@MaxLength(50, { message: "icon must not exceed 50 characters" })
icon?: string;
@IsOptional()
@IsObject({ message: "metadata must be an object" })
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,3 @@
export { CreateDomainDto } from "./create-domain.dto";
export { UpdateDomainDto } from "./update-domain.dto";
export { QueryDomainsDto } from "./query-domains.dto";

View File

@@ -0,0 +1,34 @@
import {
IsUUID,
IsOptional,
IsInt,
Min,
Max,
IsString,
} from "class-validator";
import { Type } from "class-transformer";
/**
* DTO for querying domains with filters and pagination
*/
export class QueryDomainsDto {
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
workspaceId!: string;
@IsOptional()
@IsString({ message: "search must be a string" })
search?: string;
@IsOptional()
@Type(() => Number)
@IsInt({ message: "page must be an integer" })
@Min(1, { message: "page must be at least 1" })
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt({ message: "limit must be an integer" })
@Min(1, { message: "limit must be at least 1" })
@Max(100, { message: "limit must not exceed 100" })
limit?: number;
}

View File

@@ -0,0 +1,50 @@
import {
IsString,
IsOptional,
MinLength,
MaxLength,
Matches,
IsObject,
} from "class-validator";
/**
* DTO for updating an existing domain
* All fields are optional to support partial updates
*/
export class UpdateDomainDto {
@IsOptional()
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name?: string;
@IsOptional()
@IsString({ message: "slug must be a string" })
@MinLength(1, { message: "slug must not be empty" })
@MaxLength(100, { message: "slug must not exceed 100 characters" })
@Matches(/^[a-z0-9-]+$/, {
message: "slug must contain only lowercase letters, numbers, and hyphens",
})
slug?: string;
@IsOptional()
@IsString({ message: "description must be a string" })
@MaxLength(10000, { message: "description must not exceed 10000 characters" })
description?: string | null;
@IsOptional()
@IsString({ message: "color must be a string" })
@Matches(/^#[0-9A-F]{6}$/i, {
message: "color must be a valid hex color code (e.g., #FF5733)",
})
color?: string | null;
@IsOptional()
@IsString({ message: "icon must be a string" })
@MaxLength(50, { message: "icon must not exceed 50 characters" })
icon?: string | null;
@IsOptional()
@IsObject({ message: "metadata must be an object" })
metadata?: Record<string, unknown>;
}

View File

@@ -23,7 +23,7 @@ export class UpdateEventDto {
@IsOptional()
@IsString({ message: "description must be a string" })
@MaxLength(10000, { message: "description must not exceed 10000 characters" })
description?: string | null;
description?: string;
@IsOptional()
@IsDateString({}, { message: "startTime must be a valid ISO 8601 date string" })
@@ -31,7 +31,7 @@ export class UpdateEventDto {
@IsOptional()
@IsDateString({}, { message: "endTime must be a valid ISO 8601 date string" })
endTime?: Date | null;
endTime?: Date;
@IsOptional()
@IsBoolean({ message: "allDay must be a boolean" })
@@ -40,15 +40,15 @@ export class UpdateEventDto {
@IsOptional()
@IsString({ message: "location must be a string" })
@MaxLength(500, { message: "location must not exceed 500 characters" })
location?: string | null;
location?: string;
@IsOptional()
@IsObject({ message: "recurrence must be an object" })
recurrence?: Record<string, unknown> | null;
recurrence?: Record<string, unknown>;
@IsOptional()
@IsUUID("4", { message: "projectId must be a valid UUID" })
projectId?: string | null;
projectId?: string;
@IsOptional()
@IsObject({ message: "metadata must be an object" })

View File

@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityService } from "../activity/activity.service";
import type { CreateEventDto, UpdateEventDto, QueryEventsDto } from "./dto";
@@ -157,7 +158,7 @@ export class EventsService {
id,
workspaceId,
},
data: updateEventDto,
data: updateEventDto as any,
include: {
creator: {
select: { id: true, name: true, email: true },
@@ -170,7 +171,7 @@ export class EventsService {
// Log activity
await this.activityService.logEventUpdated(workspaceId, userId, id, {
changes: updateEventDto,
changes: updateEventDto as Prisma.JsonValue,
});
return event;

View File

@@ -0,0 +1,22 @@
import {
IsString,
IsOptional,
MinLength,
MaxLength,
} from "class-validator";
/**
* DTO for quick capturing ideas with minimal fields
* Intended for rapid idea capture without complex categorization
*/
export class CaptureIdeaDto {
@IsString({ message: "content must be a string" })
@MinLength(1, { message: "content must not be empty" })
@MaxLength(50000, { message: "content must not exceed 50000 characters" })
content!: string;
@IsOptional()
@IsString({ message: "title must be a string" })
@MaxLength(500, { message: "title must not exceed 500 characters" })
title?: string;
}

View File

@@ -0,0 +1,56 @@
import { IdeaStatus, TaskPriority } from "@prisma/client";
import {
IsString,
IsOptional,
IsEnum,
IsArray,
IsUUID,
IsObject,
MinLength,
MaxLength,
} from "class-validator";
/**
* DTO for creating a new idea
*/
export class CreateIdeaDto {
@IsOptional()
@IsString({ message: "title must be a string" })
@MaxLength(500, { message: "title must not exceed 500 characters" })
title?: string;
@IsString({ message: "content must be a string" })
@MinLength(1, { message: "content must not be empty" })
@MaxLength(50000, { message: "content must not exceed 50000 characters" })
content!: string;
@IsOptional()
@IsUUID("4", { message: "domainId must be a valid UUID" })
domainId?: string;
@IsOptional()
@IsUUID("4", { message: "projectId must be a valid UUID" })
projectId?: string;
@IsOptional()
@IsEnum(IdeaStatus, { message: "status must be a valid IdeaStatus" })
status?: IdeaStatus;
@IsOptional()
@IsEnum(TaskPriority, { message: "priority must be a valid TaskPriority" })
priority?: TaskPriority;
@IsOptional()
@IsString({ message: "category must be a string" })
@MaxLength(100, { message: "category must not exceed 100 characters" })
category?: string;
@IsOptional()
@IsArray({ message: "tags must be an array" })
@IsString({ each: true, message: "each tag must be a string" })
tags?: string[];
@IsOptional()
@IsObject({ message: "metadata must be an object" })
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,4 @@
export { CreateIdeaDto } from "./create-idea.dto";
export { CaptureIdeaDto } from "./capture-idea.dto";
export { UpdateIdeaDto } from "./update-idea.dto";
export { QueryIdeasDto } from "./query-ideas.dto";

View File

@@ -0,0 +1,52 @@
import { IdeaStatus } from "@prisma/client";
import {
IsUUID,
IsOptional,
IsEnum,
IsInt,
Min,
Max,
IsString,
} from "class-validator";
import { Type } from "class-transformer";
/**
* DTO for querying ideas with filters and pagination
*/
export class QueryIdeasDto {
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
workspaceId!: string;
@IsOptional()
@IsEnum(IdeaStatus, { message: "status must be a valid IdeaStatus" })
status?: IdeaStatus;
@IsOptional()
@IsUUID("4", { message: "domainId must be a valid UUID" })
domainId?: string;
@IsOptional()
@IsUUID("4", { message: "projectId must be a valid UUID" })
projectId?: string;
@IsOptional()
@IsString({ message: "category must be a string" })
category?: string;
@IsOptional()
@IsString({ message: "search must be a string" })
search?: string;
@IsOptional()
@Type(() => Number)
@IsInt({ message: "page must be an integer" })
@Min(1, { message: "page must be at least 1" })
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt({ message: "limit must be an integer" })
@Min(1, { message: "limit must be at least 1" })
@Max(100, { message: "limit must not exceed 100" })
limit?: number;
}

View File

@@ -0,0 +1,58 @@
import { IdeaStatus, TaskPriority } from "@prisma/client";
import {
IsString,
IsOptional,
IsEnum,
IsArray,
IsUUID,
IsObject,
MinLength,
MaxLength,
} from "class-validator";
/**
* DTO for updating an existing idea
* All fields are optional to support partial updates
*/
export class UpdateIdeaDto {
@IsOptional()
@IsString({ message: "title must be a string" })
@MaxLength(500, { message: "title must not exceed 500 characters" })
title?: string;
@IsOptional()
@IsString({ message: "content must be a string" })
@MinLength(1, { message: "content must not be empty" })
@MaxLength(50000, { message: "content must not exceed 50000 characters" })
content?: string;
@IsOptional()
@IsUUID("4", { message: "domainId must be a valid UUID" })
domainId?: string;
@IsOptional()
@IsUUID("4", { message: "projectId must be a valid UUID" })
projectId?: string;
@IsOptional()
@IsEnum(IdeaStatus, { message: "status must be a valid IdeaStatus" })
status?: IdeaStatus;
@IsOptional()
@IsEnum(TaskPriority, { message: "priority must be a valid TaskPriority" })
priority?: TaskPriority;
@IsOptional()
@IsString({ message: "category must be a string" })
@MaxLength(100, { message: "category must not exceed 100 characters" })
category?: string;
@IsOptional()
@IsArray({ message: "tags must be an array" })
@IsString({ each: true, message: "each tag must be a string" })
tags?: string[];
@IsOptional()
@IsObject({ message: "metadata must be an object" })
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,130 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
UnauthorizedException,
} from "@nestjs/common";
import { IdeasService } from "./ideas.service";
import {
CreateIdeaDto,
CaptureIdeaDto,
UpdateIdeaDto,
QueryIdeasDto,
} from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
/**
* Controller for idea endpoints
* All endpoints require authentication
*/
@Controller("ideas")
@UseGuards(AuthGuard)
export class IdeasController {
constructor(private readonly ideasService: IdeasService) {}
/**
* POST /api/ideas/capture
* Quick capture endpoint for rapid idea capture
* Requires minimal fields: content only (title optional)
*/
@Post("capture")
async capture(
@Body() captureIdeaDto: CaptureIdeaDto,
@Request() req: any
) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.ideasService.capture(workspaceId, userId, captureIdeaDto);
}
/**
* POST /api/ideas
* Create a new idea with full categorization options
*/
@Post()
async create(@Body() createIdeaDto: CreateIdeaDto, @Request() req: any) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.ideasService.create(workspaceId, userId, createIdeaDto);
}
/**
* GET /api/ideas
* Get paginated ideas with optional filters
* Supports status, domain, project, category, and search filters
*/
@Get()
async findAll(@Query() query: QueryIdeasDto, @Request() req: any) {
const workspaceId = req.user?.workspaceId;
if (!workspaceId) {
throw new UnauthorizedException("Authentication required");
}
return this.ideasService.findAll({ ...query, workspaceId });
}
/**
* GET /api/ideas/:id
* Get a single idea by ID
*/
@Get(":id")
async findOne(@Param("id") id: string, @Request() req: any) {
const workspaceId = req.user?.workspaceId;
if (!workspaceId) {
throw new UnauthorizedException("Authentication required");
}
return this.ideasService.findOne(id, workspaceId);
}
/**
* PATCH /api/ideas/:id
* Update an idea
*/
@Patch(":id")
async update(
@Param("id") id: string,
@Body() updateIdeaDto: UpdateIdeaDto,
@Request() req: any
) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.ideasService.update(id, workspaceId, userId, updateIdeaDto);
}
/**
* DELETE /api/ideas/:id
* Delete an idea
*/
@Delete(":id")
async remove(@Param("id") id: string, @Request() req: any) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.ideasService.remove(id, workspaceId, userId);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { IdeasController } from "./ideas.controller";
import { IdeasService } from "./ideas.service";
import { PrismaModule } from "../prisma/prisma.module";
import { ActivityModule } from "../activity/activity.module";
import { AuthModule } from "../auth/auth.module";
@Module({
imports: [PrismaModule, ActivityModule, AuthModule],
controllers: [IdeasController],
providers: [IdeasService],
exports: [IdeasService],
})
export class IdeasModule {}

View File

@@ -0,0 +1,293 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityService } from "../activity/activity.service";
import { IdeaStatus } from "@prisma/client";
import type {
CreateIdeaDto,
CaptureIdeaDto,
UpdateIdeaDto,
QueryIdeasDto,
} from "./dto";
/**
* Service for managing ideas
*/
@Injectable()
export class IdeasService {
constructor(
private readonly prisma: PrismaService,
private readonly activityService: ActivityService
) {}
/**
* Create a new idea
*/
async create(
workspaceId: string,
userId: string,
createIdeaDto: CreateIdeaDto
) {
const data: any = {
...createIdeaDto,
workspaceId,
creatorId: userId,
status: createIdeaDto.status || IdeaStatus.CAPTURED,
priority: createIdeaDto.priority || "MEDIUM",
tags: createIdeaDto.tags || [],
metadata: createIdeaDto.metadata || {},
};
const idea = await this.prisma.idea.create({
data,
include: {
creator: {
select: { id: true, name: true, email: true },
},
domain: {
select: { id: true, name: true, color: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
});
// Log activity
await this.activityService.logIdeaCreated(
workspaceId,
userId,
idea.id,
{
title: idea.title || "Untitled",
}
);
return idea;
}
/**
* Quick capture - create an idea with minimal fields
* Optimized for rapid idea capture from the front-end
*/
async capture(
workspaceId: string,
userId: string,
captureIdeaDto: CaptureIdeaDto
) {
const data: any = {
workspaceId,
creatorId: userId,
content: captureIdeaDto.content,
title: captureIdeaDto.title,
status: IdeaStatus.CAPTURED,
priority: "MEDIUM",
tags: [],
metadata: {},
};
const idea = await this.prisma.idea.create({
data,
include: {
creator: {
select: { id: true, name: true, email: true },
},
},
});
// Log activity
await this.activityService.logIdeaCreated(
workspaceId,
userId,
idea.id,
{
quickCapture: true,
title: idea.title || "Untitled",
}
);
return idea;
}
/**
* Get paginated ideas with filters
*/
async findAll(query: QueryIdeasDto) {
const page = query.page || 1;
const limit = query.limit || 50;
const skip = (page - 1) * limit;
// Build where clause
const where: any = {
workspaceId: query.workspaceId,
};
if (query.status) {
where.status = query.status;
}
if (query.domainId) {
where.domainId = query.domainId;
}
if (query.projectId) {
where.projectId = query.projectId;
}
if (query.category) {
where.category = query.category;
}
// Add search filter if provided
if (query.search) {
where.OR = [
{ title: { contains: query.search, mode: "insensitive" } },
{ content: { contains: query.search, mode: "insensitive" } },
];
}
// Execute queries in parallel
const [data, total] = await Promise.all([
this.prisma.idea.findMany({
where,
include: {
creator: {
select: { id: true, name: true, email: true },
},
domain: {
select: { id: true, name: true, color: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
orderBy: {
createdAt: "desc",
},
skip,
take: limit,
}),
this.prisma.idea.count({ where }),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Get a single idea by ID
*/
async findOne(id: string, workspaceId: string) {
const idea = await this.prisma.idea.findUnique({
where: {
id,
workspaceId,
},
include: {
creator: {
select: { id: true, name: true, email: true },
},
domain: {
select: { id: true, name: true, color: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
});
if (!idea) {
throw new NotFoundException(`Idea with ID ${id} not found`);
}
return idea;
}
/**
* Update an idea
*/
async update(
id: string,
workspaceId: string,
userId: string,
updateIdeaDto: UpdateIdeaDto
) {
// Verify idea exists
const existingIdea = await this.prisma.idea.findUnique({
where: { id, workspaceId },
});
if (!existingIdea) {
throw new NotFoundException(`Idea with ID ${id} not found`);
}
const idea = await this.prisma.idea.update({
where: {
id,
workspaceId,
},
data: updateIdeaDto as any,
include: {
creator: {
select: { id: true, name: true, email: true },
},
domain: {
select: { id: true, name: true, color: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
});
// Log activity
await this.activityService.logIdeaUpdated(
workspaceId,
userId,
id,
{
changes: updateIdeaDto as Prisma.JsonValue,
}
);
return idea;
}
/**
* Delete an idea
*/
async remove(id: string, workspaceId: string, userId: string) {
// Verify idea exists
const idea = await this.prisma.idea.findUnique({
where: { id, workspaceId },
});
if (!idea) {
throw new NotFoundException(`Idea with ID ${id} not found`);
}
await this.prisma.idea.delete({
where: {
id,
workspaceId,
},
});
// Log activity
await this.activityService.logIdeaDeleted(
workspaceId,
userId,
id,
{
title: idea.title || "Untitled",
}
);
}
}

View File

@@ -0,0 +1,31 @@
import {
IsString,
IsOptional,
IsBoolean,
IsArray,
MinLength,
MaxLength,
IsObject,
} from "class-validator";
import { Layout } from "../types/widget.types";
/**
* DTO for creating a new user layout
*/
export class CreateLayoutDto {
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(100, { message: "name must not exceed 100 characters" })
name!: string;
@IsOptional()
@IsBoolean({ message: "isDefault must be a boolean" })
isDefault?: boolean;
@IsArray({ message: "layout must be an array" })
layout!: Layout;
@IsOptional()
@IsObject({ message: "metadata must be an object" })
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,2 @@
export { CreateLayoutDto } from "./create-layout.dto";
export { UpdateLayoutDto } from "./update-layout.dto";

View File

@@ -0,0 +1,34 @@
import {
IsString,
IsOptional,
IsBoolean,
IsArray,
MinLength,
MaxLength,
IsObject,
} from "class-validator";
import { Layout } from "../types/widget.types";
/**
* DTO for updating an existing user layout
* All fields are optional to support partial updates
*/
export class UpdateLayoutDto {
@IsOptional()
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(100, { message: "name must not exceed 100 characters" })
name?: string;
@IsOptional()
@IsBoolean({ message: "isDefault must be a boolean" })
isDefault?: boolean;
@IsOptional()
@IsArray({ message: "layout must be an array" })
layout?: Layout;
@IsOptional()
@IsObject({ message: "metadata must be an object" })
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,128 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
UseGuards,
Request,
UnauthorizedException,
} from "@nestjs/common";
import { LayoutsService } from "./layouts.service";
import { CreateLayoutDto, UpdateLayoutDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
/**
* Controller for user layout endpoints
* All endpoints require authentication
*/
@Controller("layouts")
@UseGuards(AuthGuard)
export class LayoutsController {
constructor(private readonly layoutsService: LayoutsService) {}
/**
* GET /api/layouts
* Get all layouts for the authenticated user
*/
@Get()
async findAll(@Request() req: any) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.layoutsService.findAll(workspaceId, userId);
}
/**
* GET /api/layouts/:id
* Get a single layout by ID
*/
@Get(":id")
async findOne(@Param("id") id: string, @Request() req: any) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.layoutsService.findOne(id, workspaceId, userId);
}
/**
* GET /api/layouts/default
* Get the default layout for the authenticated user
* Falls back to the most recently created layout if no default exists
*/
@Get("default")
async findDefault(@Request() req: any) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.layoutsService.findDefault(workspaceId, userId);
}
/**
* POST /api/layouts
* Create a new layout
* If isDefault is true, any existing default layout will be unset
*/
@Post()
async create(@Body() createLayoutDto: CreateLayoutDto, @Request() req: any) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.layoutsService.create(workspaceId, userId, createLayoutDto);
}
/**
* PATCH /api/layouts/:id
* Update a layout
* If isDefault is set to true, any existing default layout will be unset
*/
@Patch(":id")
async update(
@Param("id") id: string,
@Body() updateLayoutDto: UpdateLayoutDto,
@Request() req: any
) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.layoutsService.update(id, workspaceId, userId, updateLayoutDto);
}
/**
* DELETE /api/layouts/:id
* Delete a layout
*/
@Delete(":id")
async remove(@Param("id") id: string, @Request() req: any) {
const workspaceId = req.user?.workspaceId;
const userId = req.user?.id;
if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required");
}
return this.layoutsService.remove(id, workspaceId, userId);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { LayoutsController } from "./layouts.controller";
import { LayoutsService } from "./layouts.service";
import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
@Module({
imports: [PrismaModule, AuthModule],
controllers: [LayoutsController],
providers: [LayoutsService],
exports: [LayoutsService],
})
export class LayoutsModule {}

View File

@@ -0,0 +1,185 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import type { CreateLayoutDto, UpdateLayoutDto } from "./dto";
/**
* Service for managing user layouts
*/
@Injectable()
export class LayoutsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Get all layouts for a user
*/
async findAll(workspaceId: string, userId: string) {
return this.prisma.userLayout.findMany({
where: {
workspaceId,
userId,
},
orderBy: {
isDefault: "desc",
createdAt: "desc",
},
});
}
/**
* Get the default layout for a user
*/
async findDefault(workspaceId: string, userId: string) {
const layout = await this.prisma.userLayout.findFirst({
where: {
workspaceId,
userId,
isDefault: true,
},
});
// If no default layout exists, return the most recently created one
if (!layout) {
const recentLayout = await this.prisma.userLayout.findFirst({
where: {
workspaceId,
userId,
},
orderBy: {
createdAt: "desc",
},
});
if (!recentLayout) {
throw new NotFoundException(`No layouts found for this user`);
}
return recentLayout;
}
return layout;
}
/**
* Get a single layout by ID
*/
async findOne(id: string, workspaceId: string, userId: string) {
const layout = await this.prisma.userLayout.findUnique({
where: {
id,
workspaceId,
userId,
},
});
if (!layout) {
throw new NotFoundException(`Layout with ID ${id} not found`);
}
return layout;
}
/**
* Create a new layout
*/
async create(
workspaceId: string,
userId: string,
createLayoutDto: CreateLayoutDto
) {
// Use transaction to ensure atomicity when setting default
return this.prisma.$transaction(async (tx) => {
// If setting as default, unset other defaults first
if (createLayoutDto.isDefault) {
await tx.userLayout.updateMany({
where: {
workspaceId,
userId,
isDefault: true,
},
data: {
isDefault: false,
},
});
}
return tx.userLayout.create({
data: {
...createLayoutDto,
workspaceId,
userId,
isDefault: createLayoutDto.isDefault || false,
layout: (createLayoutDto.layout || []) as unknown as Prisma.JsonValue,
} as any,
});
});
}
/**
* Update a layout
*/
async update(
id: string,
workspaceId: string,
userId: string,
updateLayoutDto: UpdateLayoutDto
) {
// Use transaction to ensure atomicity when setting default
return this.prisma.$transaction(async (tx) => {
// Verify layout exists
const existingLayout = await tx.userLayout.findUnique({
where: { id, workspaceId, userId },
});
if (!existingLayout) {
throw new NotFoundException(`Layout with ID ${id} not found`);
}
// If setting as default, unset other defaults first
if (updateLayoutDto.isDefault === true) {
await tx.userLayout.updateMany({
where: {
workspaceId,
userId,
id: { not: id },
isDefault: true,
},
data: {
isDefault: false,
},
});
}
return tx.userLayout.update({
where: {
id,
workspaceId,
userId,
},
data: updateLayoutDto as any,
});
});
}
/**
* Delete a layout
*/
async remove(id: string, workspaceId: string, userId: string) {
// Verify layout exists
const layout = await this.prisma.userLayout.findUnique({
where: { id, workspaceId, userId },
});
if (!layout) {
throw new NotFoundException(`Layout with ID ${id} not found`);
}
await this.prisma.userLayout.delete({
where: {
id,
workspaceId,
userId,
},
});
}
}

View File

@@ -0,0 +1,35 @@
/**
* Widget configuration types for user layouts
*/
/**
* Base widget interface
*/
export interface Widget {
id: string;
type: string;
x: number;
y: number;
w: number;
h: number;
minW?: number;
minH?: number;
maxW?: number;
maxH?: number;
isDraggable?: boolean;
isResizable?: boolean;
isBounded?: boolean;
static?: boolean;
}
/**
* Widget with configuration
*/
export interface ConfiguredWidget extends Widget {
config?: Record<string, unknown>;
}
/**
* Layout configuration type
*/
export type Layout = ConfiguredWidget[];

View File

@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityService } from "../activity/activity.service";
import { ProjectStatus } from "@prisma/client";
@@ -177,7 +178,7 @@ export class ProjectsService {
id,
workspaceId,
},
data: updateProjectDto,
data: updateProjectDto as any,
include: {
creator: {
select: { id: true, name: true, email: true },
@@ -190,7 +191,7 @@ export class ProjectsService {
// Log activity
await this.activityService.logProjectUpdated(workspaceId, userId, id, {
changes: updateProjectDto,
changes: updateProjectDto as Prisma.JsonValue,
});
return project;

View File

@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityService } from "../activity/activity.service";
import { TaskStatus, TaskPriority } from "@prisma/client";
@@ -226,7 +227,7 @@ export class TasksService {
// Log activities
await this.activityService.logTaskUpdated(workspaceId, userId, id, {
changes: updateTaskDto,
changes: updateTaskDto as Prisma.JsonValue,
});
// Log completion if status changed to COMPLETED

View File

@@ -0,0 +1,39 @@
import {
Controller,
Get,
Param,
UseGuards,
} from "@nestjs/common";
import { WidgetsService } from "./widgets.service";
import { AuthGuard } from "../auth/guards/auth.guard";
/**
* Controller for widget definition 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) {}
/**
* GET /api/widgets
* List all available widget definitions
* Returns only active widgets
*/
@Get()
async findAll() {
return this.widgetsService.findAll();
}
/**
* GET /api/widgets/:name
* Get a widget definition by name
* Useful for fetching widget configuration schemas
*/
@Get(":name")
async findByName(@Param("name") name: string) {
return this.widgetsService.findByName(name);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { WidgetsController } from "./widgets.controller";
import { WidgetsService } from "./widgets.service";
import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
@Module({
imports: [PrismaModule, AuthModule],
controllers: [WidgetsController],
providers: [WidgetsService],
exports: [WidgetsService],
})
export class WidgetsModule {}

View File

@@ -0,0 +1,59 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
/**
* Service for managing widget definitions
* Provides read-only access to available widget definitions
*/
@Injectable()
export class WidgetsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Get all active widget definitions
*/
async findAll() {
return this.prisma.widgetDefinition.findMany({
where: {
isActive: true,
},
orderBy: {
name: "asc",
},
});
}
/**
* Get a widget definition by name
*/
async findByName(name: string) {
const widget = await this.prisma.widgetDefinition.findUnique({
where: {
name,
},
});
if (!widget) {
throw new NotFoundException(`Widget definition with name '${name}' not found`);
}
return widget;
}
/**
* Get a widget definition by ID
*/
async findOne(id: string) {
const widget = await this.prisma.widgetDefinition.findUnique({
where: {
id,
},
});
if (!widget) {
throw new NotFoundException(`Widget definition with ID ${id} not found`);
}
return widget;
}
}

View File

@@ -19,9 +19,11 @@
"@mosaic/ui": "workspace:*",
"@tanstack/react-query": "^5.90.20",
"date-fns": "^4.1.0",
"lucide-react": "^0.563.0",
"next": "^16.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-grid-layout": "^2.2.2"
},
"devDependencies": {
"@mosaic/config": "workspace:*",
@@ -31,6 +33,7 @@
"@types/node": "^22.13.4",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react-grid-layout": "^2.1.0",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.2.4",
"jsdom": "^26.0.0",

View File

@@ -0,0 +1,48 @@
import { RecentTasksWidget } from "@/components/dashboard/RecentTasksWidget";
import { UpcomingEventsWidget } from "@/components/dashboard/UpcomingEventsWidget";
import { QuickCaptureWidget } from "@/components/dashboard/QuickCaptureWidget";
import { DomainOverviewWidget } from "@/components/dashboard/DomainOverviewWidget";
import { mockTasks } from "@/lib/api/tasks";
import { mockEvents } from "@/lib/api/events";
export default function DashboardPage() {
// TODO: Replace with real API call when backend is ready
// const { data: tasks, isLoading: tasksLoading } = useQuery({
// queryKey: ["tasks"],
// queryFn: fetchTasks,
// });
// const { data: events, isLoading: eventsLoading } = useQuery({
// queryKey: ["events"],
// queryFn: fetchEvents,
// });
const tasks = mockTasks;
const events = mockEvents;
const tasksLoading = false;
const eventsLoading = false;
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600 mt-2">
Welcome back! Here's your overview
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top row: Domain Overview and Quick Capture */}
<div className="lg:col-span-2">
<DomainOverviewWidget tasks={tasks} isLoading={tasksLoading} />
</div>
<RecentTasksWidget tasks={tasks} isLoading={tasksLoading} />
<UpcomingEventsWidget events={events} isLoading={eventsLoading} />
<div className="lg:col-span-2">
<QuickCaptureWidget />
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,46 @@
import type { Task } from "@mosaic/shared";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
interface DomainOverviewWidgetProps {
tasks: Task[];
isLoading: boolean;
}
export function DomainOverviewWidget({ tasks, isLoading }: DomainOverviewWidgetProps) {
if (isLoading) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
<span className="ml-3 text-gray-600">Loading overview...</span>
</div>
</div>
);
}
const stats = {
total: tasks.length,
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
highPriority: tasks.filter((t) => t.priority === TaskPriority.HIGH).length,
};
const StatCard = ({ label, value, color }: { label: string; value: number; color: string }) => (
<div className={`p-4 rounded-lg bg-gradient-to-br ${color}`}>
<div className="text-3xl font-bold text-white mb-1">{value}</div>
<div className="text-sm text-white/90">{label}</div>
</div>
);
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Domain Overview</h2>
<div className="grid grid-cols-2 gap-4">
<StatCard label="Total Tasks" value={stats.total} color="from-blue-500 to-blue-600" />
<StatCard label="In Progress" value={stats.inProgress} color="from-green-500 to-green-600" />
<StatCard label="Completed" value={stats.completed} color="from-purple-500 to-purple-600" />
<StatCard label="High Priority" value={stats.highPriority} color="from-red-500 to-red-600" />
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { Button } from "@mosaic/ui";
import { useRouter } from "next/navigation";
export function QuickCaptureWidget() {
const [idea, setIdea] = useState("");
const router = useRouter();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!idea.trim()) return;
// TODO: Implement quick capture API call
// For now, just show a success indicator
console.log("Quick capture:", idea);
setIdea("");
};
const goToTasks = () => {
router.push("/tasks");
};
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Quick Capture</h2>
<p className="text-sm text-gray-600 mb-4">
Quickly jot down ideas or brain dumps
</p>
<form onSubmit={handleSubmit} className="space-y-3">
<textarea
value={idea}
onChange={(e) => setIdea(e.target.value)}
placeholder="What's on your mind?"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows={3}
/>
<div className="flex gap-2">
<Button type="submit" variant="primary" size="sm">
Save Note
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={goToTasks}
>
Create Task
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import type { Task } from "@mosaic/shared";
import { TaskPriority } from "@mosaic/shared";
import { formatDate } from "@/lib/utils/date-format";
import { TaskStatus } from "@mosaic/shared";
import Link from "next/link";
interface RecentTasksWidgetProps {
tasks: Task[];
isLoading: boolean;
}
const statusIcons: Record<TaskStatus, string> = {
[TaskStatus.NOT_STARTED]: "⚪",
[TaskStatus.IN_PROGRESS]: "🟢",
[TaskStatus.PAUSED]: "⏸️",
[TaskStatus.COMPLETED]: "✅",
[TaskStatus.ARCHIVED]: "💤",
};
export function RecentTasksWidget({ tasks, isLoading }: RecentTasksWidgetProps) {
if (isLoading) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
<span className="ml-3 text-gray-600">Loading tasks...</span>
</div>
</div>
);
}
const recentTasks = tasks.slice(0, 5);
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Recent Tasks</h2>
<Link
href="/tasks"
className="text-sm text-blue-600 hover:text-blue-700"
>
View all
</Link>
</div>
{recentTasks.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No tasks yet</p>
) : (
<ul className="space-y-3">
{recentTasks.map((task) => (
<li
key={task.id}
className="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
>
<span className="text-lg flex-shrink-0" aria-label={`Status: ${task.status}`}>
{statusIcons[task.status]}
</span>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 text-sm truncate">
{task.title}
</h3>
<div className="flex items-center gap-2 mt-1">
{task.priority !== TaskPriority.LOW && (
<span
className={`text-xs px-2 py-0.5 rounded-full ${
task.priority === TaskPriority.HIGH
? "bg-red-100 text-red-700"
: "bg-blue-100 text-blue-700"
}`}
>
{task.priority}
</span>
)}
{task.dueDate && (
<span className="text-xs text-gray-500">
{formatDate(task.dueDate)}
</span>
)}
</div>
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import type { Event } from "@mosaic/shared";
import { formatTime, formatDate } from "@/lib/utils/date-format";
import Link from "next/link";
interface UpcomingEventsWidgetProps {
events: Event[];
isLoading: boolean;
}
export function UpcomingEventsWidget({ events, isLoading }: UpcomingEventsWidgetProps) {
if (isLoading) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
<span className="ml-3 text-gray-600">Loading events...</span>
</div>
</div>
);
}
const upcomingEvents = events.slice(0, 4);
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Upcoming Events</h2>
<Link
href="/calendar"
className="text-sm text-blue-600 hover:text-blue-700"
>
View calendar
</Link>
</div>
{upcomingEvents.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No upcoming events</p>
) : (
<div className="space-y-3">
{upcomingEvents.map((event) => (
<div
key={event.id}
className="flex items-start gap-3 p-3 rounded-lg border-l-4 border-blue-500 bg-gray-50"
>
<div className="flex-shrink-0 text-center min-w-[3.5rem]">
<div className="text-xs text-gray-500 uppercase font-semibold">
{formatDate(event.startTime).split(',')[0]}
</div>
<div className="text-sm font-medium text-gray-900">
{formatTime(event.startTime)}
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 text-sm truncate">
{event.title}
</h3>
{event.location && (
<p className="text-xs text-gray-500 mt-0.5">📍 {event.location}</p>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,182 @@
/**
* HUD container - main dashboard interface
*/
import { useMemo } from "react";
import { Button } from "@mosaic/ui";
import { RotateCcw } from "lucide-react";
import { WidgetGrid } from "./WidgetGrid";
import { WidgetRenderer } from "./WidgetRenderer";
import { useLayout } from "@/lib/hooks/useLayout";
import type { WidgetPlacement } from "@mosaic/shared";
export interface HUDProps {
className?: string;
}
/**
* Registry of available widget components
* This will be populated with actual widget components
*/
const WIDGET_REGISTRY = {
TasksWidget: {
name: "tasks",
displayName: "Tasks",
description: "View and manage your tasks",
defaultWidth: 2,
defaultHeight: 3,
minWidth: 1,
minHeight: 2,
},
CalendarWidget: {
name: "calendar",
displayName: "Calendar",
description: "Upcoming events and schedule",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
},
QuickCaptureWidget: {
name: "quick-capture",
displayName: "Quick Capture",
description: "Capture ideas and notes",
defaultWidth: 2,
defaultHeight: 1,
minWidth: 1,
minHeight: 1,
},
AgentStatusWidget: {
name: "agent-status",
displayName: "Agent Status",
description: "View running agent sessions",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 1,
},
} as const;
type WidgetRegistryKey = keyof typeof WIDGET_REGISTRY;
export function HUD({ className = "" }: HUDProps) {
const {
currentLayout,
updateLayout,
addWidget,
removeWidget,
switchLayout,
resetLayout,
} = useLayout();
const isEditing = true; // For now, always in edit mode (can be toggled later)
const handleLayoutChange = (newLayout: readonly { i: string; x: number; y: number; w: number; h: number }[]) => {
updateLayout([...newLayout] as WidgetPlacement[]);
};
const handleAddWidget = (widgetType: WidgetRegistryKey) => {
const widgetConfig = WIDGET_REGISTRY[widgetType];
const widgetId = `${widgetType.toLowerCase()}-${Date.now()}`;
// Find the next available position
const maxY = currentLayout?.layout.reduce((max, w) => Math.max(max, w.y + w.h), 0) || 0;
const newWidget = {
i: widgetId,
x: 0,
y: maxY,
w: widgetConfig.defaultWidth,
h: widgetConfig.defaultHeight,
minW: widgetConfig.minWidth,
minH: widgetConfig.minHeight,
isDraggable: true,
isResizable: true,
};
addWidget(newWidget);
};
const handleResetLayout = () => {
if (confirm("Are you sure you want to reset the layout? This will remove all widgets.")) {
resetLayout();
}
};
const widgetComponents = useMemo(() => {
if (!currentLayout?.layout) return [];
return currentLayout.layout.map((widget) => (
<WidgetRenderer
key={widget.i}
widget={widget}
isEditing={isEditing}
onRemove={removeWidget}
/>
));
}, [currentLayout?.layout, isEditing, removeWidget]);
return (
<div className={`hud-container ${className}`}>
{/* Toolbar */}
<div className="bg-white border-b border-gray-200 px-6 py-4 mb-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<div className="flex items-center gap-2">
<select
value={currentLayout?.id || ""}
onChange={(e) => switchLayout(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Default Layout</option>
{/* Add more layout options here */}
</select>
<Button
onClick={handleResetLayout}
variant="secondary"
size="sm"
className="flex items-center gap-2"
>
<RotateCcw className="w-4 h-4" />
Reset
</Button>
{/* Widget type selector */}
<div className="relative">
<select
onChange={(e) => {
const widgetType = e.target.value as WidgetRegistryKey;
if (widgetType) {
handleAddWidget(widgetType);
e.target.value = "";
}
}}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
defaultValue=""
>
<option value="" disabled>
Add Widget
</option>
{Object.entries(WIDGET_REGISTRY).map(([key, config]) => (
<option key={key} value={key}>
{config.displayName}
</option>
))}
</select>
</div>
</div>
</div>
</div>
{/* Widget Grid */}
<WidgetGrid
layout={currentLayout?.layout || []}
onLayoutChange={handleLayoutChange}
isEditing={isEditing}
>
{widgetComponents}
</WidgetGrid>
</div>
);
}

View File

@@ -0,0 +1,117 @@
/**
* Widget grid container using react-grid-layout
*/
import { Responsive as ResponsiveGridLayout, useContainerWidth } from "react-grid-layout";
import type { ReactNode } from "react";
import type { WidgetPlacement } from "@mosaic/shared";
// Import CSS for react-grid-layout
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
export interface WidgetGridProps {
children: ReactNode[];
layout: WidgetPlacement[];
onLayoutChange?: (layout: readonly WidgetPlacement[]) => void;
isEditing?: boolean;
breakpoints?: { [key: string]: number };
cols?: { [key: string]: number };
rowHeight?: number;
margin?: [number, number];
containerPadding?: [number, number];
className?: string;
}
const DEFAULT_BREAKPOINTS = {
lg: 1200,
md: 996,
sm: 768,
xs: 480,
xxs: 0,
};
const DEFAULT_COLS = {
lg: 4,
md: 3,
sm: 2,
xs: 1,
xxs: 1,
};
export function WidgetGrid({
children,
layout,
onLayoutChange,
isEditing = false,
breakpoints = DEFAULT_BREAKPOINTS,
cols = DEFAULT_COLS,
rowHeight = 100,
margin = [16, 16],
containerPadding = [16, 16],
className = "",
}: WidgetGridProps) {
// Use hook to measure container width
const { width, containerRef, mounted } = useContainerWidth({ measureBeforeMount: true });
// Convert our WidgetPlacement to react-grid-layout's Layout format
const rglLayout = layout.map((widget) => {
const layoutItem: {
i: string;
x: number;
y: number;
w: number;
h: number;
minW: number;
maxW?: number;
minH: number;
maxH?: number;
static?: boolean;
isDraggable?: boolean;
isResizable?: boolean;
} = {
i: widget.i,
x: widget.x,
y: widget.y,
w: widget.w,
h: widget.h,
minW: widget.minW || 1,
minH: widget.minH || 1,
};
if (widget.maxW !== undefined) layoutItem.maxW = widget.maxW;
if (widget.maxH !== undefined) layoutItem.maxH = widget.maxH;
if (widget.static) layoutItem.static = true;
if (isEditing && widget.isDraggable !== false) layoutItem.isDraggable = true;
if (isEditing && widget.isResizable !== false) layoutItem.isResizable = true;
return layoutItem;
});
const handleLayoutChange = (layout: readonly any[]) => {
if (onLayoutChange) {
onLayoutChange([...layout] as WidgetPlacement[]);
}
};
return (
<div ref={containerRef} style={{ width: "100%" }}>
{mounted && (
<ResponsiveGridLayout
className={`widget-grid ${className}`}
layouts={{ lg: rglLayout }}
breakpoints={breakpoints}
cols={cols}
rowHeight={rowHeight}
margin={margin}
containerPadding={containerPadding}
onLayoutChange={handleLayoutChange}
width={width}
>
{children.map((child, index) => (
<div key={layout[index]?.i || index}>{child}</div>
))}
</ResponsiveGridLayout>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
/**
* Widget renderer - renders the appropriate widget component based on type
*/
import { WidgetWrapper } from "./WidgetWrapper";
import { TasksWidget, CalendarWidget, QuickCaptureWidget, AgentStatusWidget } from "@/components/widgets";
import type { WidgetPlacement } from "@mosaic/shared";
export interface WidgetRendererProps {
widget: WidgetPlacement;
isEditing?: boolean;
onRemove?: (widgetId: string) => void;
}
const WIDGET_COMPONENTS = {
tasks: TasksWidget,
calendar: CalendarWidget,
"quick-capture": QuickCaptureWidget,
"agent-status": AgentStatusWidget,
};
const WIDGET_CONFIG = {
tasks: {
displayName: "Tasks",
description: "View and manage your tasks",
},
calendar: {
displayName: "Calendar",
description: "Upcoming events and schedule",
},
"quick-capture": {
displayName: "Quick Capture",
description: "Capture ideas and notes",
},
"agent-status": {
displayName: "Agent Status",
description: "View running agent sessions",
},
};
export function WidgetRenderer({ widget, isEditing = false, onRemove }: WidgetRendererProps) {
// Extract widget type from ID (e.g., "tasks-123" -> "tasks")
const widgetType = widget.i.split("-")[0] as keyof typeof WIDGET_COMPONENTS;
const WidgetComponent = WIDGET_COMPONENTS[widgetType];
const config = WIDGET_CONFIG[widgetType] || { displayName: "Widget", description: "" };
if (!WidgetComponent) {
const wrapperProps = {
id: widget.i,
title: "Unknown Widget",
isEditing: isEditing,
...(onRemove && { onRemove: () => onRemove(widget.i) }),
};
return (
<WidgetWrapper {...wrapperProps}>
<div className="text-gray-500 text-sm">Widget type not found: {widgetType}</div>
</WidgetWrapper>
);
}
const wrapperProps = {
id: widget.i,
title: config.displayName,
isEditing: isEditing,
...(onRemove && { onRemove: () => onRemove(widget.i) }),
};
return (
<WidgetWrapper {...wrapperProps}>
<WidgetComponent id={widget.i} />
</WidgetWrapper>
);
}

View File

@@ -0,0 +1,109 @@
/**
* Widget wrapper with drag/resize handles and edit controls
*/
import { ReactNode, useState } from "react";
import { Card, CardHeader, CardContent } from "@mosaic/ui";
import { GripVertical, Maximize2, Minimize2, X, Settings } from "lucide-react";
export interface WidgetWrapperProps {
id: string;
title: string;
children: ReactNode;
isEditing?: boolean;
isCollapsed?: boolean;
onEdit?: () => void;
onRemove?: () => void;
onToggleCollapse?: () => void;
className?: string;
}
export function WidgetWrapper({
id,
title,
children,
isEditing = false,
isCollapsed = false,
onEdit,
onRemove,
onToggleCollapse,
className = "",
}: WidgetWrapperProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<Card
id={id}
className={`relative flex flex-col h-full ${isCollapsed ? "min-h-[60px]" : ""} ${className}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Drag handle */}
{isEditing && (
<div className="absolute top-2 left-2 z-10 cursor-grab active:cursor-grabbing p-1 rounded hover:bg-gray-100 group">
<GripVertical className="w-4 h-4 text-gray-400 group-hover:text-gray-600" />
</div>
)}
{/* Header */}
<CardHeader
className={`px-4 py-3 border-b border-gray-200 flex items-center justify-between ${
isEditing ? "pr-20" : ""
}`}
>
<h3 className="text-sm font-semibold text-gray-900">{title}</h3>
{/* Action buttons */}
<div className="flex items-center gap-1">
{!isEditing && (isHovered || isCollapsed) && (
<>
{onToggleCollapse && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleCollapse();
}}
className="p-1 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700"
title={isCollapsed ? "Expand" : "Collapse"}
>
{isCollapsed ? (
<Maximize2 className="w-4 h-4" />
) : (
<Minimize2 className="w-4 h-4" />
)}
</button>
)}
{!isCollapsed && onEdit && (
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="p-1 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700"
title="Settings"
>
<Settings className="w-4 h-4" />
</button>
)}
</>
)}
{isEditing && onRemove && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="p-1 rounded hover:bg-red-100 text-gray-500 hover:text-red-600"
title="Remove widget"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</CardHeader>
{/* Content */}
{!isCollapsed && <CardContent className="flex-1 overflow-auto p-4">{children}</CardContent>}
</Card>
);
}

View File

@@ -0,0 +1,12 @@
/**
* HUD components
*/
export { HUD } from "./HUD";
export { WidgetGrid } from "./WidgetGrid";
export { WidgetWrapper } from "./WidgetWrapper";
export { WidgetRenderer } from "./WidgetRenderer";
export type { WidgetWrapperProps } from "./WidgetWrapper";
export type { WidgetGridProps } from "./WidgetGrid";
export type { HUDProps } from "./HUD";
export type { WidgetRendererProps } from "./WidgetRenderer";

View File

@@ -10,6 +10,7 @@ export function Navigation() {
const { user } = useAuth();
const navItems = [
{ href: "/", label: "Dashboard" },
{ href: "/tasks", label: "Tasks" },
{ href: "/calendar", label: "Calendar" },
];
@@ -19,7 +20,7 @@ export function Navigation() {
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-8">
<Link href="/tasks" className="text-xl font-bold text-gray-900">
<Link href="/" className="text-xl font-bold text-gray-900">
Mosaic Stack
</Link>
<div className="flex gap-4">

View File

@@ -0,0 +1,169 @@
/**
* Agent Status Widget - shows running agents
*/
import { useState, useEffect } from "react";
import { Bot, Activity, AlertCircle, CheckCircle, Clock } from "lucide-react";
import type { WidgetProps } from "@mosaic/shared";
interface Agent {
id: string;
name: string;
status: "IDLE" | "WORKING" | "WAITING" | "ERROR" | "TERMINATED";
currentTask?: string;
lastHeartbeat: string;
taskCount: number;
}
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps) {
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Mock data for now - will fetch from API later
useEffect(() => {
setIsLoading(true);
setTimeout(() => {
setAgents([
{
id: "1",
name: "Code Review Agent",
status: "WORKING",
currentTask: "Reviewing PR #123",
lastHeartbeat: new Date().toISOString(),
taskCount: 42,
},
{
id: "2",
name: "Documentation Agent",
status: "IDLE",
lastHeartbeat: new Date().toISOString(),
taskCount: 15,
},
{
id: "3",
name: "Test Runner Agent",
status: "ERROR",
currentTask: "Failed to run tests",
lastHeartbeat: new Date(Date.now() - 300000).toISOString(),
taskCount: 28,
},
]);
setIsLoading(false);
}, 500);
}, []);
const getStatusIcon = (status: Agent["status"]) => {
switch (status) {
case "WORKING":
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
case "IDLE":
return <Clock className="w-4 h-4 text-gray-400" />;
case "WAITING":
return <Clock className="w-4 h-4 text-yellow-500" />;
case "ERROR":
return <AlertCircle className="w-4 h-4 text-red-500" />;
case "TERMINATED":
return <CheckCircle className="w-4 h-4 text-gray-500" />;
default:
return <Clock className="w-4 h-4 text-gray-400" />;
}
};
const getStatusText = (status: Agent["status"]) => {
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
};
const getTimeSinceLastHeartbeat = (timestamp: string) => {
const now = new Date();
const last = new Date(timestamp);
const diffMs = now.getTime() - last.getTime();
if (diffMs < 60000) return "Just now";
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m ago`;
if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h ago`;
return `${Math.floor(diffMs / 86400000)}d ago`;
};
const stats = {
total: agents.length,
working: agents.filter((a) => a.status === "WORKING").length,
idle: agents.filter((a) => a.status === "IDLE").length,
error: agents.filter((a) => a.status === "ERROR").length,
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500 text-sm">Loading agents...</div>
</div>
);
}
return (
<div className="flex flex-col h-full space-y-3">
{/* Summary stats */}
<div className="grid grid-cols-4 gap-1 text-center text-xs">
<div className="bg-gray-50 rounded p-2">
<div className="text-lg font-bold text-gray-900">{stats.total}</div>
<div className="text-gray-500">Total</div>
</div>
<div className="bg-blue-50 rounded p-2">
<div className="text-lg font-bold text-blue-600">{stats.working}</div>
<div className="text-blue-500">Working</div>
</div>
<div className="bg-gray-100 rounded p-2">
<div className="text-lg font-bold text-gray-600">{stats.idle}</div>
<div className="text-gray-500">Idle</div>
</div>
<div className="bg-red-50 rounded p-2">
<div className="text-lg font-bold text-red-600">{stats.error}</div>
<div className="text-red-500">Error</div>
</div>
</div>
{/* Agent list */}
<div className="flex-1 overflow-auto space-y-2">
{agents.length === 0 ? (
<div className="text-center text-gray-500 text-sm py-4">
No agents configured
</div>
) : (
agents.map((agent) => (
<div
key={agent.id}
className={`p-3 rounded-lg border ${
agent.status === "ERROR"
? "bg-red-50 border-red-200"
: agent.status === "WORKING"
? "bg-blue-50 border-blue-200"
: "bg-gray-50 border-gray-200"
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-gray-600" />
<span className="text-sm font-medium text-gray-900">
{agent.name}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500">
{getStatusIcon(agent.status)}
<span>{getStatusText(agent.status)}</span>
</div>
</div>
{agent.currentTask && (
<div className="text-xs text-gray-600 mb-1">{agent.currentTask}</div>
)}
<div className="flex items-center justify-between text-xs text-gray-400">
<span>{agent.taskCount} tasks completed</span>
<span>{getTimeSinceLastHeartbeat(agent.lastHeartbeat)}</span>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
/**
* Calendar Widget - displays upcoming events
*/
import { useState, useEffect } from "react";
import { Calendar as CalendarIcon, Clock, MapPin } from "lucide-react";
import type { WidgetProps } from "@mosaic/shared";
interface Event {
id: string;
title: string;
startTime: string;
endTime?: string;
location?: string;
allDay: boolean;
}
export function CalendarWidget({ id: _id, config: _config }: WidgetProps) {
const [events, setEvents] = useState<Event[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Mock data for now - will fetch from API later
useEffect(() => {
setIsLoading(true);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
setTimeout(() => {
setEvents([
{
id: "1",
title: "Team Standup",
startTime: new Date(today.setHours(9, 0, 0, 0)).toISOString(),
endTime: new Date(today.setHours(9, 30, 0, 0)).toISOString(),
location: "Zoom",
allDay: false,
},
{
id: "2",
title: "Project Review",
startTime: new Date(today.setHours(14, 0, 0, 0)).toISOString(),
endTime: new Date(today.setHours(15, 0, 0, 0)).toISOString(),
location: "Conference Room A",
allDay: false,
},
{
id: "3",
title: "Sprint Planning",
startTime: new Date(tomorrow.setHours(10, 0, 0, 0)).toISOString(),
endTime: new Date(tomorrow.setHours(12, 0, 0, 0)).toISOString(),
allDay: false,
},
]);
setIsLoading(false);
}, 500);
}, []);
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
};
const formatDay = (dateString: string) => {
const date = new Date(dateString);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) {
return "Today";
} else if (date.toDateString() === tomorrow.toDateString()) {
return "Tomorrow";
}
return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
};
const getUpcomingEvents = () => {
const now = new Date();
return events
.filter((e) => new Date(e.startTime) > now)
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime())
.slice(0, 5);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500 text-sm">Loading events...</div>
</div>
);
}
const upcomingEvents = getUpcomingEvents();
return (
<div className="flex flex-col h-full space-y-3">
{/* Header */}
<div className="flex items-center gap-2 text-gray-700">
<CalendarIcon className="w-4 h-4" />
<span className="text-sm font-medium">Upcoming Events</span>
</div>
{/* Event list */}
<div className="flex-1 overflow-auto space-y-3">
{upcomingEvents.length === 0 ? (
<div className="text-center text-gray-500 text-sm py-4">No upcoming events</div>
) : (
upcomingEvents.map((event) => (
<div key={event.id} className="border-l-2 border-blue-500 pl-3 py-1">
<div className="text-sm font-medium text-gray-900">{event.title}</div>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
{!event.allDay && (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>
{formatTime(event.startTime)}
{event.endTime && ` - ${formatTime(event.endTime)}`}
</span>
</div>
)}
{event.location && (
<div className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
<span>{event.location}</span>
</div>
)}
<div className="text-gray-400">{formatDay(event.startTime)}</div>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
/**
* Quick Capture Widget - idea/brain dump input
*/
import { useState } from "react";
import { Send, Lightbulb } from "lucide-react";
import type { WidgetProps } from "@mosaic/shared";
export function QuickCaptureWidget({ id: _id, config: _config }: WidgetProps) {
const [input, setInput] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [recentCaptures, setRecentCaptures] = useState<string[]>([]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isSubmitting) return;
setIsSubmitting(true);
const idea = input.trim();
try {
// TODO: Replace with actual API call
// await api.ideas.create({ content: idea });
// Add to recent captures for visual feedback
setRecentCaptures((prev) => [idea, ...prev].slice(0, 3));
setInput("");
} catch (error) {
console.error("Failed to capture idea:", error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex flex-col h-full space-y-3">
{/* Header */}
<div className="flex items-center gap-2 text-gray-700">
<Lightbulb className="w-4 h-4 text-yellow-500" />
<span className="text-sm font-medium">Quick Capture</span>
</div>
{/* Input form */}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Capture an idea..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isSubmitting}
/>
<button
type="submit"
disabled={!input.trim() || isSubmitting}
className="px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</button>
</form>
{/* Recent captures */}
{recentCaptures.length > 0 && (
<div className="flex-1 overflow-auto">
<div className="text-xs text-gray-500 mb-2">Recently captured:</div>
<div className="space-y-2">
{recentCaptures.map((capture, index) => (
<div
key={index}
className="p-2 bg-gray-50 rounded text-sm text-gray-700"
>
{capture}
</div>
))}
</div>
</div>
)}
{/* Tips */}
{recentCaptures.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-400 text-xs space-y-1">
<div>Capture ideas quickly</div>
<div>They'll be organized later</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,134 @@
/**
* Tasks Widget - displays task summary and list
*/
import { useState, useEffect } from "react";
import { CheckCircle, Circle, Clock, AlertCircle } from "lucide-react";
import type { WidgetProps } from "@mosaic/shared";
interface Task {
id: string;
title: string;
status: string;
priority: string;
dueDate?: string;
}
export function TasksWidget({ }: WidgetProps) {
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Mock data for now - will fetch from API later
useEffect(() => {
setIsLoading(true);
// Simulate API call
setTimeout(() => {
setTasks([
{
id: "1",
title: "Complete project documentation",
status: "IN_PROGRESS",
priority: "HIGH",
dueDate: "2024-02-01",
},
{
id: "2",
title: "Review pull requests",
status: "NOT_STARTED",
priority: "MEDIUM",
dueDate: "2024-02-02",
},
{
id: "3",
title: "Update dependencies",
status: "COMPLETED",
priority: "LOW",
dueDate: "2024-01-30",
},
]);
setIsLoading(false);
}, 500);
}, []);
const getPriorityIcon = (priority: string) => {
switch (priority) {
case "HIGH":
return <AlertCircle className="w-4 h-4 text-red-500" />;
case "MEDIUM":
return <Clock className="w-4 h-4 text-yellow-500" />;
case "LOW":
return <Circle className="w-4 h-4 text-gray-400" />;
default:
return <Circle className="w-4 h-4 text-gray-400" />;
}
};
const getStatusIcon = (status: string) => {
return status === "COMPLETED" ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<Circle className="w-4 h-4 text-gray-400" />
);
};
const stats = {
total: tasks.length,
inProgress: tasks.filter((t) => t.status === "IN_PROGRESS").length,
completed: tasks.filter((t) => t.status === "COMPLETED").length,
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500 text-sm">Loading tasks...</div>
</div>
);
}
return (
<div className="flex flex-col h-full space-y-3">
{/* Summary stats */}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="bg-gray-50 rounded p-2">
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
<div className="text-xs text-gray-500">Total</div>
</div>
<div className="bg-blue-50 rounded p-2">
<div className="text-2xl font-bold text-blue-600">{stats.inProgress}</div>
<div className="text-xs text-blue-500">In Progress</div>
</div>
<div className="bg-green-50 rounded p-2">
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
<div className="text-xs text-green-500">Done</div>
</div>
</div>
{/* Task list */}
<div className="flex-1 overflow-auto space-y-2">
{tasks.length === 0 ? (
<div className="text-center text-gray-500 text-sm py-4">No tasks yet</div>
) : (
tasks.slice(0, 5).map((task) => (
<div
key={task.id}
className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded transition-colors"
>
{getStatusIcon(task.status)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{task.title}
</div>
{task.dueDate && (
<div className="text-xs text-gray-500">
Due: {new Date(task.dueDate).toLocaleDateString()}
</div>
)}
</div>
{getPriorityIcon(task.priority)}
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
/**
* Widget components
*/
export { TasksWidget } from "./TasksWidget";
export { CalendarWidget } from "./CalendarWidget";
export { QuickCaptureWidget } from "./QuickCaptureWidget";
export { AgentStatusWidget } from "./AgentStatusWidget";

View File

@@ -0,0 +1,22 @@
/**
* Widget component registry - React-specific definitions
*/
import type { ComponentType } from "react";
import type { WidgetProps } from "@mosaic/shared";
/**
* Widget component registry
*/
export interface WidgetComponent {
name: string;
displayName: string;
description: string;
component: ComponentType<WidgetProps>;
defaultWidth: number;
defaultHeight: number;
minWidth: number;
minHeight: number;
maxWidth?: number;
maxHeight?: number;
}

View File

@@ -0,0 +1,5 @@
/**
* Custom hooks
*/
export { useLayout, useWorkspaceId } from "./useLayout";

View File

@@ -0,0 +1,227 @@
/**
* Hook for managing widget layouts
*/
import { useCallback, useState, useEffect } from "react";
import type { WidgetPlacement, LayoutConfig } from "@mosaic/shared";
const STORAGE_KEY = "mosaic-layout";
const DEFAULT_LAYOUT_NAME = "default";
/**
* Local storage key for user's workspace preference
*/
const WORKSPACE_KEY = "mosaic-workspace-id";
/**
* Hook to manage widget layout state
*/
export function useLayout() {
const [layouts, setLayouts] = useState<Record<string, LayoutConfig>>({});
const [currentLayoutId, setCurrentLayoutId] = useState<string>(DEFAULT_LAYOUT_NAME);
const [isLoading, setIsLoading] = useState(true);
// Load layouts from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
setLayouts(parsed);
}
// Load current layout ID preference
const storedLayoutId = localStorage.getItem(`${STORAGE_KEY}-current`);
if (storedLayoutId) {
setCurrentLayoutId(storedLayoutId);
}
} catch (error) {
console.error("Failed to load layouts from localStorage:", error);
} finally {
setIsLoading(false);
}
}, []);
// Save layouts to localStorage whenever they change
useEffect(() => {
if (!isLoading) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts));
localStorage.setItem(`${STORAGE_KEY}-current`, currentLayoutId);
} catch (error) {
console.error("Failed to save layouts to localStorage:", error);
}
}
}, [layouts, currentLayoutId, isLoading]);
const currentLayout = layouts[currentLayoutId];
const updateLayout = useCallback(
(layoutItems: WidgetPlacement[]) => {
setLayouts((prev) => ({
...prev,
[currentLayoutId]: {
id: currentLayoutId,
name: prev[currentLayoutId]?.name || "My Layout",
layout: layoutItems,
},
}));
},
[currentLayoutId]
);
const addWidget = useCallback(
(widget: WidgetPlacement) => {
setLayouts((prev) => {
const currentLayoutData = prev[currentLayoutId];
if (!currentLayoutData) {
return prev;
}
return {
...prev,
[currentLayoutId]: {
...currentLayoutData,
layout: [...currentLayoutData.layout, widget],
},
};
});
},
[currentLayoutId]
);
const removeWidget = useCallback(
(widgetId: string) => {
setLayouts((prev) => {
const currentLayoutData = prev[currentLayoutId];
if (!currentLayoutData) {
return prev;
}
return {
...prev,
[currentLayoutId]: {
...currentLayoutData,
layout: currentLayoutData.layout.filter((w) => w.i !== widgetId),
},
};
});
},
[currentLayoutId]
);
const updateWidget = useCallback(
(widgetId: string, updates: Partial<WidgetPlacement>) => {
setLayouts((prev) => {
const currentLayoutData = prev[currentLayoutId];
if (!currentLayoutData) {
return prev;
}
return {
...prev,
[currentLayoutId]: {
...currentLayoutData,
layout: currentLayoutData.layout.map((w) =>
w.i === widgetId ? { ...w, ...updates } : w
),
},
};
});
},
[currentLayoutId]
);
const createLayout = useCallback(
(name: string) => {
const id = `layout-${Date.now()}`;
setLayouts((prev) => ({
...prev,
[id]: {
id,
name,
layout: [],
},
}));
setCurrentLayoutId(id);
return id;
},
[]
);
const deleteLayout = useCallback(
(layoutId: string) => {
setLayouts((prev) => {
const { [layoutId]: deleted, ...rest } = prev;
// If we deleted the current layout, switch to default
if (layoutId === currentLayoutId) {
const remainingIds = Object.keys(rest);
setCurrentLayoutId(remainingIds[0] || DEFAULT_LAYOUT_NAME);
}
return rest;
});
},
[currentLayoutId]
);
const renameLayout = useCallback((layoutId: string, name: string) => {
setLayouts((prev) => {
const existing = prev[layoutId];
if (!existing) return prev;
return {
...prev,
[layoutId]: {
...existing,
name,
},
};
});
}, []);
const resetLayout = useCallback(() => {
setLayouts({
[DEFAULT_LAYOUT_NAME]: {
id: DEFAULT_LAYOUT_NAME,
name: "Default Layout",
layout: [],
},
});
setCurrentLayoutId(DEFAULT_LAYOUT_NAME);
}, []);
return {
layouts,
currentLayout,
currentLayoutId,
isLoading,
updateLayout,
addWidget,
removeWidget,
updateWidget,
createLayout,
deleteLayout,
renameLayout,
switchLayout: setCurrentLayoutId,
resetLayout,
};
}
/**
* Hook to get the current workspace ID
*/
export function useWorkspaceId(): string | null {
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
useEffect(() => {
try {
const stored = localStorage.getItem(WORKSPACE_KEY);
if (stored) {
setWorkspaceId(stored);
}
} catch (error) {
console.error("Failed to load workspace ID from localStorage:", error);
}
}, []);
return workspaceId;
}