refactor: migrate to monorepo structure with Docker, PocketBase, and e2e tests
- Reorganize into apps/, packages/, docs/, e2e/, pocketbase/ directories - Add Dockerfiles for web, worker, and PocketBase services - Add docker-compose.yml for local orchestration - Add turbo.json for monorepo task management - Add Playwright e2e test infrastructure - Add PocketBase backend with migrations - Remove Vite/Next.js/ESLint/PostCSS config files - Update package.json with workspace dependencies - Add .env.example and .dockerignore
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createPocketBaseClient } from './pocketbase';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract auth token from request cookies
|
||||
*/
|
||||
export function getAuthToken(request: NextRequest): string | null {
|
||||
return request.cookies.get('pb_auth')?.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated user from request
|
||||
* Returns null if not authenticated
|
||||
*/
|
||||
export async function getAuthUser(request: NextRequest): Promise<AuthUser | null> {
|
||||
const token = getAuthToken(request);
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const pb = createPocketBaseClient(token);
|
||||
const authData = await pb.collection('users').authRefresh();
|
||||
|
||||
return {
|
||||
id: authData.record.id,
|
||||
email: authData.record.email,
|
||||
name: authData.record.name || authData.record.email,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authentication — throws if not authenticated
|
||||
*/
|
||||
export async function requireAuth(request: NextRequest): Promise<AuthUser> {
|
||||
const user = await getAuthUser(request);
|
||||
if (!user) {
|
||||
throw new AuthError('Not authenticated', 401);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth middleware for API routes
|
||||
* Wraps a route handler and ensures authentication
|
||||
*/
|
||||
|
||||
// Overload: when context type T is provided, context is required in both handler and return
|
||||
export function withAuth<T>(
|
||||
handler: (request: NextRequest, user: AuthUser, context: T) => Promise<NextResponse>
|
||||
): (request: NextRequest, context: T) => Promise<NextResponse>;
|
||||
|
||||
// Overload: no context type — context param is not passed
|
||||
export function withAuth(
|
||||
handler: (request: NextRequest, user: AuthUser) => Promise<NextResponse>
|
||||
): (request: NextRequest) => Promise<NextResponse>;
|
||||
|
||||
// Implementation
|
||||
export function withAuth<T>(
|
||||
handler: (request: NextRequest, user: AuthUser, context?: T) => Promise<NextResponse>
|
||||
) {
|
||||
return async (request: NextRequest, context?: T): Promise<NextResponse> => {
|
||||
try {
|
||||
const user = await requireAuth(request);
|
||||
return await handler(request, user, context);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: error.code, message: error.message } },
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'INTERNAL_ERROR', message: 'Authentication failed' } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for auth errors
|
||||
*/
|
||||
export class AuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number = 401,
|
||||
public code: string = 'UNAUTHORIZED'
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API error class for consistent error responses
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number = 400,
|
||||
public code: string = 'BAD_REQUEST',
|
||||
public details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create error responses
|
||||
*/
|
||||
export function createErrorResponse(
|
||||
code: string,
|
||||
message: string,
|
||||
status: number = 400,
|
||||
details?: unknown
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
...(details !== undefined ? { details } : {}),
|
||||
},
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Structured API error response
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API errors and show appropriate toast
|
||||
*/
|
||||
export function handleApiError(error: unknown, context?: string): void {
|
||||
const apiError = parseApiError(error);
|
||||
|
||||
const title = context || 'Error';
|
||||
const description = apiError.message;
|
||||
|
||||
switch (apiError.code) {
|
||||
case 'VALIDATION_ERROR':
|
||||
toast.error(title, { description });
|
||||
break;
|
||||
case 'UNAUTHORIZED':
|
||||
toast.error('Session expired', {
|
||||
description: 'Please log in again',
|
||||
});
|
||||
break;
|
||||
case 'FORBIDDEN':
|
||||
toast.error('Access denied', { description });
|
||||
break;
|
||||
case 'NOT_FOUND':
|
||||
toast.error('Not found', { description });
|
||||
break;
|
||||
default:
|
||||
toast.error(title, { description });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse error response from API
|
||||
*/
|
||||
export function parseApiError(error: unknown): ApiErrorResponse {
|
||||
if (error instanceof Error) {
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
const parsed = JSON.parse(error.message) as {
|
||||
error?: ApiErrorResponse;
|
||||
};
|
||||
if (parsed.error) {
|
||||
return parsed.error;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'error' in error
|
||||
) {
|
||||
const apiError = (error as { error: ApiErrorResponse }).error;
|
||||
return apiError;
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'An unexpected error occurred',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success toast
|
||||
*/
|
||||
export function showSuccess(message: string, description?: string): void {
|
||||
toast.success(message, { description });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error toast
|
||||
*/
|
||||
export function showError(message: string, description?: string): void {
|
||||
toast.error(message, { description });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show warning toast
|
||||
*/
|
||||
export function showWarning(message: string, description?: string): void {
|
||||
toast.warning(message, { description });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show info toast
|
||||
*/
|
||||
export function showInfo(message: string, description?: string): void {
|
||||
toast.info(message, { description });
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Create a singleton event bus
|
||||
class EventBus extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
// Increase max listeners for high-traffic scenarios
|
||||
this.setMaxListeners(100);
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus();
|
||||
|
||||
// Event type definitions
|
||||
export interface TaskCompletedEvent {
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
projectId?: string;
|
||||
domain: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface HabitCompletedEvent {
|
||||
habitId: string;
|
||||
habitTitle: string;
|
||||
date: string;
|
||||
domain: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface HabitStreakBrokenEvent {
|
||||
habitId: string;
|
||||
habitTitle: string;
|
||||
previousStreak: number;
|
||||
domain: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface MilestoneReachedEvent {
|
||||
milestoneId: string;
|
||||
milestoneTitle: string;
|
||||
projectId: string;
|
||||
domain: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface ProjectStatusChangedEvent {
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
oldStatus: string;
|
||||
newStatus: string;
|
||||
domain: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface ReportGeneratedEvent {
|
||||
reportId: string;
|
||||
reportTitle: string;
|
||||
reportType: string;
|
||||
domain: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface AgentTaskCompletedEvent {
|
||||
agentTaskId: string;
|
||||
agentId: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// Event names as constants
|
||||
export const EVENTS = {
|
||||
TASK_COMPLETED: 'task.completed',
|
||||
HABIT_COMPLETED: 'habit.completed',
|
||||
HABIT_STREAK_BROKEN: 'habit.streak_broken',
|
||||
MILESTONE_REACHED: 'milestone.reached',
|
||||
PROJECT_STATUS_CHANGED: 'project.status_changed',
|
||||
REPORT_GENERATED: 'report.generated',
|
||||
AGENT_TASK_COMPLETED: 'agent_task.completed',
|
||||
} as const;
|
||||
|
||||
// Type-safe emit helper
|
||||
export function emitEvent<T>(event: string, data: T): void {
|
||||
eventBus.emit(event, data);
|
||||
}
|
||||
|
||||
// Type-safe listener helper
|
||||
export function onEvent<T>(event: string, handler: (data: T) => void): () => void {
|
||||
eventBus.on(event, handler);
|
||||
return () => eventBus.off(event, handler);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { registerTaskTools } from './tools/tasks';
|
||||
import { registerHabitTools } from './tools/habits';
|
||||
import { registerProjectTools } from './tools/projects';
|
||||
import { registerNoteTools } from './tools/notes';
|
||||
import { registerReportTools } from './tools/reports';
|
||||
import { registerMilestoneTools } from './tools/milestones';
|
||||
import { registerDomainTools } from './tools/domains';
|
||||
import { registerTagTools } from './tools/tags';
|
||||
import { registerAgentTools } from './tools/agents';
|
||||
import { registerWebhookTools } from './tools/webhooks';
|
||||
import { registerAnalyticsTools } from './tools/analytics';
|
||||
|
||||
export function createMcpServer(): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'project-e',
|
||||
version: '1.0.0',
|
||||
}, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
});
|
||||
|
||||
// Register all tools
|
||||
registerTaskTools(server);
|
||||
registerHabitTools(server);
|
||||
registerProjectTools(server);
|
||||
registerNoteTools(server);
|
||||
registerReportTools(server);
|
||||
registerMilestoneTools(server);
|
||||
registerDomainTools(server);
|
||||
registerTagTools(server);
|
||||
registerAgentTools(server);
|
||||
registerWebhookTools(server);
|
||||
registerAnalyticsTools(server);
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { createAdminClient } from '@/lib/pocketbase';
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
function textContent(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
export function registerAgentTools(server: McpServer) {
|
||||
server.tool('create_agent', 'Create a new agent', {
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
domain: z.string(),
|
||||
status: z.enum(['active', 'disabled']).optional(),
|
||||
permission_tier: z.enum(['full_access', 'read_only', 'content_creator', 'task_manager', 'custom']).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const agent = await pb.collection('agents').create({
|
||||
name: args.name,
|
||||
description: args.description || '',
|
||||
domain: args.domain,
|
||||
status: args.status || 'active',
|
||||
permission_tier: args.permission_tier || 'read_only',
|
||||
tags: args.tags || [],
|
||||
api_key: crypto.randomUUID(),
|
||||
});
|
||||
return textContent(JSON.stringify({ success: true, agent }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_agent', 'Get an agent by ID', {
|
||||
agent_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const agent = await pb.collection('agents').getOne(args.agent_id);
|
||||
return textContent(JSON.stringify({ success: true, agent }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('list_agents', 'List agents with optional filters', {
|
||||
status: z.enum(['active', 'disabled']).optional(),
|
||||
domain: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const filters: string[] = [];
|
||||
if (args.status) filters.push(`status = "${args.status}"`);
|
||||
if (args.domain) filters.push(`domain = "${args.domain}"`);
|
||||
|
||||
const page = Math.floor((args.offset || 0) / (args.limit || 20)) + 1;
|
||||
const result = await pb.collection('agents').getList(page, args.limit || 20, {
|
||||
filter: filters.join(' && ') || '',
|
||||
sort: '-created',
|
||||
});
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
agents: result.items,
|
||||
total: result.totalItems,
|
||||
page: result.page,
|
||||
limit: args.limit || 20,
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('update_agent', 'Update an existing agent', {
|
||||
agent_id: z.string(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['active', 'disabled']).optional(),
|
||||
permission_tier: z.enum(['full_access', 'read_only', 'content_creator', 'task_manager', 'custom']).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const { agent_id, ...updateData } = args;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updateData)) {
|
||||
if (value !== undefined) cleaned[key] = value;
|
||||
}
|
||||
const agent = await pb.collection('agents').update(agent_id, cleaned);
|
||||
return textContent(JSON.stringify({ success: true, agent }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('delete_agent', 'Delete an agent', {
|
||||
agent_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
await pb.collection('agents').delete(args.agent_id);
|
||||
return textContent(JSON.stringify({ success: true, deleted: args.agent_id }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { createAdminClient } from '@/lib/pocketbase';
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
function textContent(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
export function registerAnalyticsTools(server: McpServer) {
|
||||
server.tool('get_analytics', 'Get analytics data for a given period', {
|
||||
period_days: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const days = args.period_days || 30;
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
const startStr = startDate.toISOString();
|
||||
|
||||
// Task completion rate
|
||||
const tasks = await pb.collection('tasks').getFullList({
|
||||
filter: `created >= "${startStr}"`,
|
||||
});
|
||||
const completedTasks = tasks.filter((t: Record<string, unknown>) => t.status === 'done');
|
||||
const taskCompletionRate = tasks.length > 0
|
||||
? Math.round((completedTasks.length / tasks.length) * 100)
|
||||
: 0;
|
||||
|
||||
// Habit consistency
|
||||
const habits = await pb.collection('habits').getFullList();
|
||||
const habitLogs = await pb.collection('habit_logs').getFullList({
|
||||
filter: `logged_at >= "${startStr}"`,
|
||||
});
|
||||
const habitConsistency = habits.length > 0
|
||||
? Math.round((habitLogs.length / (habits.length * days)) * 100)
|
||||
: 0;
|
||||
|
||||
// Time tracked
|
||||
const timeEntries = await pb.collection('task_time_entries').getFullList({
|
||||
filter: `started_at >= "${startStr}"`,
|
||||
});
|
||||
const totalTimeMinutes = timeEntries.reduce(
|
||||
(sum: number, e: Record<string, unknown>) => sum + ((e.duration_minutes as number) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
// Active streaks
|
||||
const activeStreaks = habits.filter(
|
||||
(h: Record<string, unknown>) => ((h.current_streak as number) || 0) > 0,
|
||||
);
|
||||
const bestStreak = Math.max(
|
||||
...habits.map((h: Record<string, unknown>) => (h.best_streak as number) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
analytics: {
|
||||
taskCompletionRate,
|
||||
habitConsistency,
|
||||
totalTimeMinutes,
|
||||
activeStreaks: activeStreaks.length,
|
||||
bestStreak,
|
||||
period: days,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_time_summary', 'Get aggregated time tracking summary', {
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const startDate = args.start_date || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const endDate = args.end_date || new Date().toISOString();
|
||||
|
||||
const entries = await pb.collection('task_time_entries').getFullList({
|
||||
filter: `started_at >= "${startDate}" && started_at <= "${endDate}"`,
|
||||
});
|
||||
|
||||
const byDomain: Record<string, number> = {};
|
||||
const byProject: Record<string, number> = {};
|
||||
const byTag: Record<string, number> = {};
|
||||
let totalMinutes = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const duration = (entry as Record<string, unknown>).duration_minutes as number || 0;
|
||||
totalMinutes += duration;
|
||||
|
||||
const taskId = (entry as Record<string, unknown>).task_id as string;
|
||||
if (taskId) {
|
||||
try {
|
||||
const task = await pb.collection('tasks').getOne(taskId);
|
||||
const taskRecord = task as unknown as Record<string, unknown>;
|
||||
const domain = taskRecord.domain as string;
|
||||
if (domain) {
|
||||
byDomain[domain] = (byDomain[domain] || 0) + duration;
|
||||
}
|
||||
|
||||
const projectId = taskRecord.project_id as string | undefined;
|
||||
if (projectId) {
|
||||
byProject[projectId] = (byProject[projectId] || 0) + duration;
|
||||
}
|
||||
|
||||
const tags = (taskRecord.tags as string[]) || [];
|
||||
for (const tag of tags) {
|
||||
byTag[tag] = (byTag[tag] || 0) + duration;
|
||||
}
|
||||
} catch {
|
||||
// Skip if task not found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
time_summary: {
|
||||
totalMinutes,
|
||||
byDomain,
|
||||
byProject,
|
||||
byTag,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('search', 'Search across tasks, habits, projects, notes, and reports', {
|
||||
query: z.string(),
|
||||
types: z.array(z.string()).optional(),
|
||||
limit: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const types = args.types || ['tasks', 'habits', 'projects', 'notes', 'reports'];
|
||||
const limit = args.limit || 10;
|
||||
const safeQuery = args.query.replace(/"/g, '\\"');
|
||||
const results: Array<{ type: string; items: unknown[] }> = [];
|
||||
|
||||
for (const type of types) {
|
||||
try {
|
||||
let filter = '';
|
||||
switch (type) {
|
||||
case 'tasks':
|
||||
filter = `title ~ "${safeQuery}" || description ~ "${safeQuery}"`;
|
||||
break;
|
||||
case 'habits':
|
||||
filter = `name ~ "${safeQuery}" || description ~ "${safeQuery}"`;
|
||||
break;
|
||||
case 'projects':
|
||||
filter = `name ~ "${safeQuery}" || description ~ "${safeQuery}"`;
|
||||
break;
|
||||
case 'notes':
|
||||
filter = `title ~ "${safeQuery}" || content ~ "${safeQuery}"`;
|
||||
break;
|
||||
case 'reports':
|
||||
filter = `title ~ "${safeQuery}" || summary ~ "${safeQuery}"`;
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
const items = await pb.collection(type).getList(1, limit, { filter });
|
||||
results.push({ type, items: items.items });
|
||||
} catch {
|
||||
// Skip collections that fail
|
||||
}
|
||||
}
|
||||
|
||||
return textContent(JSON.stringify({ success: true, results }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_agent_activity', 'Get recent agent activity', {
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const page = Math.floor((args.offset || 0) / (args.limit || 20)) + 1;
|
||||
const result = await pb.collection('agent_activity').getList(page, args.limit || 20, {
|
||||
sort: '-created',
|
||||
});
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
activity: result.items,
|
||||
total: result.totalItems,
|
||||
page: result.page,
|
||||
limit: args.limit || 20,
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { createAdminClient } from '@/lib/pocketbase';
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
function textContent(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
export function registerDomainTools(server: McpServer) {
|
||||
server.tool('create_domain', 'Create a new domain', {
|
||||
name: z.string(),
|
||||
color: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const domain = await pb.collection('domains').create({
|
||||
name: args.name,
|
||||
color: args.color || null,
|
||||
icon: args.icon || null,
|
||||
sort_order: args.sort_order || 0,
|
||||
});
|
||||
return textContent(JSON.stringify({ success: true, domain }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_domain', 'Get a domain by ID', {
|
||||
domain_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const domain = await pb.collection('domains').getOne(args.domain_id);
|
||||
return textContent(JSON.stringify({ success: true, domain }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('list_domains', 'List all domains', {
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const page = Math.floor((args.offset || 0) / (args.limit || 50)) + 1;
|
||||
const result = await pb.collection('domains').getList(page, args.limit || 50, {
|
||||
sort: 'sort_order',
|
||||
});
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
domains: result.items,
|
||||
total: result.totalItems,
|
||||
page: result.page,
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('update_domain', 'Update an existing domain', {
|
||||
domain_id: z.string(),
|
||||
name: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const { domain_id, ...updateData } = args;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updateData)) {
|
||||
if (value !== undefined) cleaned[key] = value;
|
||||
}
|
||||
const domain = await pb.collection('domains').update(domain_id, cleaned);
|
||||
return textContent(JSON.stringify({ success: true, domain }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('delete_domain', 'Delete a domain', {
|
||||
domain_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
await pb.collection('domains').delete(args.domain_id);
|
||||
return textContent(JSON.stringify({ success: true, deleted: args.domain_id }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { createAdminClient } from '@/lib/pocketbase';
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
function textContent(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
export function registerHabitTools(server: McpServer) {
|
||||
server.tool('create_habit', 'Create a new habit', {
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
domain: z.string(),
|
||||
frequency: z.enum(['daily', 'weekly', 'custom']).optional(),
|
||||
difficulty: z.enum(['easy', 'medium', 'hard']).optional(),
|
||||
goal_per_period: z.number().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const habit = await pb.collection('habits').create({
|
||||
name: args.name,
|
||||
description: args.description || '',
|
||||
domain: args.domain,
|
||||
frequency: args.frequency || 'daily',
|
||||
difficulty: args.difficulty || 'medium',
|
||||
completion_mode: 'quick',
|
||||
goal_per_period: args.goal_per_period || 1,
|
||||
tags: args.tags || [],
|
||||
active: true,
|
||||
current_streak: 0,
|
||||
best_streak: 0,
|
||||
total_completions: 0,
|
||||
score: 0,
|
||||
});
|
||||
return textContent(JSON.stringify({ success: true, habit }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_habit', 'Get a habit by ID', {
|
||||
habit_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const habit = await pb.collection('habits').getOne(args.habit_id);
|
||||
return textContent(JSON.stringify({ success: true, habit }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('list_habits', 'List habits with optional filters', {
|
||||
domain: z.string().optional(),
|
||||
frequency: z.enum(['daily', 'weekly', 'custom']).optional(),
|
||||
active: z.boolean().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const filters: string[] = [];
|
||||
if (args.domain) filters.push(`domain = "${args.domain}"`);
|
||||
if (args.frequency) filters.push(`frequency = "${args.frequency}"`);
|
||||
if (args.active !== undefined) filters.push(`active = ${args.active}`);
|
||||
|
||||
const page = Math.floor((args.offset || 0) / (args.limit || 20)) + 1;
|
||||
const result = await pb.collection('habits').getList(page, args.limit || 20, {
|
||||
filter: filters.join(' && ') || '',
|
||||
sort: '-created',
|
||||
});
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
habits: result.items,
|
||||
total: result.totalItems,
|
||||
page: result.page,
|
||||
limit: args.limit || 20,
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('update_habit', 'Update an existing habit', {
|
||||
habit_id: z.string(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
frequency: z.enum(['daily', 'weekly', 'custom']).optional(),
|
||||
difficulty: z.enum(['easy', 'medium', 'hard']).optional(),
|
||||
active: z.boolean().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const { habit_id, ...updateData } = args;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updateData)) {
|
||||
if (value !== undefined) cleaned[key] = value;
|
||||
}
|
||||
const habit = await pb.collection('habits').update(habit_id, cleaned);
|
||||
return textContent(JSON.stringify({ success: true, habit }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('delete_habit', 'Delete a habit', {
|
||||
habit_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
await pb.collection('habits').delete(args.habit_id);
|
||||
return textContent(JSON.stringify({ success: true, deleted: args.habit_id }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('log_habit_completion', 'Log a habit completion', {
|
||||
habit_id: z.string(),
|
||||
completed: z.boolean().optional(),
|
||||
notes: z.string().optional(),
|
||||
value: z.number().optional(),
|
||||
mood: z.number().optional(),
|
||||
logged_at: z.string().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const log = await pb.collection('habit_logs').create({
|
||||
habit_id: args.habit_id,
|
||||
completed: args.completed !== undefined ? args.completed : true,
|
||||
notes: args.notes || '',
|
||||
value: args.value,
|
||||
mood: args.mood,
|
||||
logged_at: args.logged_at || new Date().toISOString(),
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
// Update habit streak and count
|
||||
const habit = await pb.collection('habits').getOne(args.habit_id);
|
||||
const now = new Date();
|
||||
const lastUpdated = habit.updated ? new Date(habit.updated) : null;
|
||||
let newStreak = (habit as Record<string, unknown>).current_streak as number || 0;
|
||||
|
||||
if (lastUpdated) {
|
||||
const daysDiff = Math.floor((now.getTime() - lastUpdated.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (daysDiff === 1) newStreak += 1;
|
||||
else if (daysDiff > 1) newStreak = 1;
|
||||
} else {
|
||||
newStreak = 1;
|
||||
}
|
||||
|
||||
const bestStreak = Math.max(newStreak, (habit as Record<string, unknown>).best_streak as number || 0);
|
||||
|
||||
await pb.collection('habits').update(args.habit_id, {
|
||||
current_streak: newStreak,
|
||||
best_streak: bestStreak,
|
||||
total_completions: ((habit as Record<string, unknown>).total_completions as number || 0) + 1,
|
||||
});
|
||||
|
||||
return textContent(JSON.stringify({ success: true, log, habit_id: args.habit_id, current_streak: newStreak }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_habit_streaks', 'Get streak information for all active habits', {}, async () => {
|
||||
try {
|
||||
const habits = await pb.collection('habits').getFullList({
|
||||
filter: 'active = true',
|
||||
sort: '-current_streak',
|
||||
});
|
||||
const streaks = habits.map((h: Record<string, unknown>) => ({
|
||||
habit_id: h.id,
|
||||
name: h.name,
|
||||
current_streak: h.current_streak,
|
||||
best_streak: h.best_streak,
|
||||
total_completions: h.total_completions,
|
||||
score: h.score,
|
||||
}));
|
||||
return textContent(JSON.stringify({ success: true, streaks }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { createAdminClient } from '@/lib/pocketbase';
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
function textContent(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
export function registerMilestoneTools(server: McpServer) {
|
||||
server.tool('create_milestone', 'Create a new milestone', {
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
project_id: z.string(),
|
||||
domain: z.string(),
|
||||
status: z.enum(['planned', 'in_progress', 'complete']).optional(),
|
||||
target_date: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const milestone = await pb.collection('milestones').create({
|
||||
name: args.name,
|
||||
description: args.description || '',
|
||||
project_id: args.project_id,
|
||||
domain: args.domain,
|
||||
status: args.status || 'planned',
|
||||
target_date: args.target_date || null,
|
||||
sort_order: args.sort_order || 0,
|
||||
tags: args.tags || [],
|
||||
tasks: [],
|
||||
dependencies: [],
|
||||
});
|
||||
return textContent(JSON.stringify({ success: true, milestone }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_milestone', 'Get a milestone by ID', {
|
||||
milestone_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const milestone = await pb.collection('milestones').getOne(args.milestone_id);
|
||||
return textContent(JSON.stringify({ success: true, milestone }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('list_milestones', 'List milestones with optional filters', {
|
||||
project_id: z.string().optional(),
|
||||
status: z.enum(['planned', 'in_progress', 'complete']).optional(),
|
||||
domain: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const filters: string[] = [];
|
||||
if (args.project_id) filters.push(`project_id = "${args.project_id}"`);
|
||||
if (args.status) filters.push(`status = "${args.status}"`);
|
||||
if (args.domain) filters.push(`domain = "${args.domain}"`);
|
||||
|
||||
const page = Math.floor((args.offset || 0) / (args.limit || 20)) + 1;
|
||||
const result = await pb.collection('milestones').getList(page, args.limit || 20, {
|
||||
filter: filters.join(' && ') || '',
|
||||
sort: 'sort_order',
|
||||
});
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
milestones: result.items,
|
||||
total: result.totalItems,
|
||||
page: result.page,
|
||||
limit: args.limit || 20,
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('update_milestone', 'Update an existing milestone', {
|
||||
milestone_id: z.string(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['planned', 'in_progress', 'complete']).optional(),
|
||||
target_date: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const { milestone_id, ...updateData } = args;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updateData)) {
|
||||
if (value !== undefined) cleaned[key] = value;
|
||||
}
|
||||
if (cleaned.status === 'complete') {
|
||||
cleaned.completed_at = new Date().toISOString();
|
||||
}
|
||||
const milestone = await pb.collection('milestones').update(milestone_id, cleaned);
|
||||
return textContent(JSON.stringify({ success: true, milestone }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('delete_milestone', 'Delete a milestone', {
|
||||
milestone_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
await pb.collection('milestones').delete(args.milestone_id);
|
||||
return textContent(JSON.stringify({ success: true, deleted: args.milestone_id }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { createAdminClient } from '@/lib/pocketbase';
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
function textContent(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
export function registerNoteTools(server: McpServer) {
|
||||
server.tool('create_note', 'Create a new note', {
|
||||
title: z.string(),
|
||||
content: z.string().optional(),
|
||||
domain: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
project_id: z.string().optional(),
|
||||
is_pinned: z.boolean().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const content = args.content || '';
|
||||
const wordCount = content.split(/\s+/).filter(Boolean).length;
|
||||
const note = await pb.collection('notes').create({
|
||||
title: args.title,
|
||||
content,
|
||||
domain: args.domain,
|
||||
tags: args.tags || [],
|
||||
project_id: args.project_id || '',
|
||||
is_pinned: args.is_pinned || false,
|
||||
is_archived: false,
|
||||
word_count: wordCount,
|
||||
});
|
||||
return textContent(JSON.stringify({ success: true, note }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_note', 'Get a note by ID', {
|
||||
note_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const note = await pb.collection('notes').getOne(args.note_id);
|
||||
return textContent(JSON.stringify({ success: true, note }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('list_notes', 'List notes with optional filters', {
|
||||
domain: z.string().optional(),
|
||||
project_id: z.string().optional(),
|
||||
is_archived: z.boolean().optional(),
|
||||
is_pinned: z.boolean().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const filters: string[] = [];
|
||||
if (args.domain) filters.push(`domain = "${args.domain}"`);
|
||||
if (args.project_id) filters.push(`project_id = "${args.project_id}"`);
|
||||
if (args.is_archived !== undefined) filters.push(`is_archived = ${args.is_archived}`);
|
||||
if (args.is_pinned !== undefined) filters.push(`is_pinned = ${args.is_pinned}`);
|
||||
|
||||
const page = Math.floor((args.offset || 0) / (args.limit || 20)) + 1;
|
||||
const result = await pb.collection('notes').getList(page, args.limit || 20, {
|
||||
filter: filters.join(' && ') || '',
|
||||
sort: '-created',
|
||||
});
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
notes: result.items,
|
||||
total: result.totalItems,
|
||||
page: result.page,
|
||||
limit: args.limit || 20,
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('update_note', 'Update an existing note', {
|
||||
note_id: z.string(),
|
||||
title: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
project_id: z.string().optional(),
|
||||
is_pinned: z.boolean().optional(),
|
||||
is_archived: z.boolean().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const { note_id, ...updateData } = args;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updateData)) {
|
||||
if (value !== undefined) cleaned[key] = value;
|
||||
}
|
||||
if (typeof cleaned.content === 'string') {
|
||||
cleaned.word_count = cleaned.content.split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
const note = await pb.collection('notes').update(note_id, cleaned);
|
||||
return textContent(JSON.stringify({ success: true, note }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('delete_note', 'Delete a note', {
|
||||
note_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
await pb.collection('notes').delete(args.note_id);
|
||||
return textContent(JSON.stringify({ success: true, deleted: args.note_id }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_note_graph', 'Get the note graph showing connections between notes', {}, async () => {
|
||||
try {
|
||||
const notes = await pb.collection('notes').getFullList();
|
||||
const links = await pb.collection('note_links').getFullList();
|
||||
|
||||
const nodes = notes.map((n: Record<string, unknown>) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
domain: n.domain,
|
||||
}));
|
||||
|
||||
const edges = links.map((l: Record<string, unknown>) => ({
|
||||
source: l.source_note_id,
|
||||
target: l.target_note_id,
|
||||
label: l.label || '',
|
||||
}));
|
||||
|
||||
return textContent(JSON.stringify({ success: true, graph: { nodes, edges } }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { createAdminClient } from '@/lib/pocketbase';
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
function textContent(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
export function registerProjectTools(server: McpServer) {
|
||||
server.tool('create_project', 'Create a new project', {
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['active', 'paused', 'archived']).optional(),
|
||||
domain: z.string(),
|
||||
color: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
owner: z.string().optional(),
|
||||
start_date: z.string().optional(),
|
||||
target_date: z.string().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const project = await pb.collection('projects').create({
|
||||
name: args.name,
|
||||
description: args.description || '',
|
||||
status: args.status || 'active',
|
||||
domain: args.domain,
|
||||
color: args.color || null,
|
||||
icon: args.icon || null,
|
||||
tags: args.tags || [],
|
||||
owner: args.owner || '',
|
||||
start_date: args.start_date || null,
|
||||
target_date: args.target_date || null,
|
||||
});
|
||||
return textContent(JSON.stringify({ success: true, project }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_project', 'Get a project by ID', {
|
||||
project_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const project = await pb.collection('projects').getOne(args.project_id);
|
||||
return textContent(JSON.stringify({ success: true, project }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('list_projects', 'List projects with optional filters', {
|
||||
status: z.enum(['active', 'paused', 'archived']).optional(),
|
||||
domain: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const filters: string[] = [];
|
||||
if (args.status) filters.push(`status = "${args.status}"`);
|
||||
if (args.domain) filters.push(`domain = "${args.domain}"`);
|
||||
|
||||
const page = Math.floor((args.offset || 0) / (args.limit || 20)) + 1;
|
||||
const result = await pb.collection('projects').getList(page, args.limit || 20, {
|
||||
filter: filters.join(' && ') || '',
|
||||
sort: '-created',
|
||||
});
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
projects: result.items,
|
||||
total: result.totalItems,
|
||||
page: result.page,
|
||||
limit: args.limit || 20,
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('update_project', 'Update an existing project', {
|
||||
project_id: z.string(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['active', 'paused', 'archived']).optional(),
|
||||
domain: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
owner: z.string().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const { project_id, ...updateData } = args;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updateData)) {
|
||||
if (value !== undefined) cleaned[key] = value;
|
||||
}
|
||||
const project = await pb.collection('projects').update(project_id, cleaned);
|
||||
return textContent(JSON.stringify({ success: true, project }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('delete_project', 'Delete a project', {
|
||||
project_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
await pb.collection('projects').delete(args.project_id);
|
||||
return textContent(JSON.stringify({ success: true, deleted: args.project_id }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_project_progress', 'Get project progress based on task completion', {
|
||||
project_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const tasks = await pb.collection('tasks').getFullList({
|
||||
filter: `project_id = "${args.project_id}"`,
|
||||
});
|
||||
const total = tasks.length;
|
||||
const done = tasks.filter((t: Record<string, unknown>) => t.status === 'done').length;
|
||||
const progress = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
project_id: args.project_id,
|
||||
total_tasks: total,
|
||||
completed_tasks: done,
|
||||
progress,
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { createAdminClient } from '@/lib/pocketbase';
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
function textContent(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
export function registerReportTools(server: McpServer) {
|
||||
server.tool('create_report', 'Create a new report', {
|
||||
title: z.string(),
|
||||
type: z.enum(['weekly', 'monthly', 'project', 'habit', 'custom']),
|
||||
domain: z.string(),
|
||||
date_range_start: z.string(),
|
||||
date_range_end: z.string(),
|
||||
summary: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
is_draft: z.boolean().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const report = await pb.collection('reports').create({
|
||||
title: args.title,
|
||||
type: args.type,
|
||||
domain: args.domain,
|
||||
date_range: {
|
||||
start: args.date_range_start,
|
||||
end: args.date_range_end,
|
||||
},
|
||||
sections: [],
|
||||
summary: args.summary || '',
|
||||
tags: args.tags || [],
|
||||
is_draft: args.is_draft !== undefined ? args.is_draft : true,
|
||||
});
|
||||
return textContent(JSON.stringify({ success: true, report }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_report', 'Get a report by ID', {
|
||||
report_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const report = await pb.collection('reports').getOne(args.report_id);
|
||||
return textContent(JSON.stringify({ success: true, report }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('list_reports', 'List reports with optional filters', {
|
||||
type: z.enum(['weekly', 'monthly', 'project', 'habit', 'custom']).optional(),
|
||||
domain: z.string().optional(),
|
||||
is_draft: z.boolean().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const filters: string[] = [];
|
||||
if (args.type) filters.push(`type = "${args.type}"`);
|
||||
if (args.domain) filters.push(`domain = "${args.domain}"`);
|
||||
if (args.is_draft !== undefined) filters.push(`is_draft = ${args.is_draft}`);
|
||||
|
||||
const page = Math.floor((args.offset || 0) / (args.limit || 20)) + 1;
|
||||
const result = await pb.collection('reports').getList(page, args.limit || 20, {
|
||||
filter: filters.join(' && ') || '',
|
||||
sort: '-created',
|
||||
});
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
reports: result.items,
|
||||
total: result.totalItems,
|
||||
page: result.page,
|
||||
limit: args.limit || 20,
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('update_report', 'Update an existing report', {
|
||||
report_id: z.string(),
|
||||
title: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
is_draft: z.boolean().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const { report_id, ...updateData } = args;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updateData)) {
|
||||
if (value !== undefined) cleaned[key] = value;
|
||||
}
|
||||
const report = await pb.collection('reports').update(report_id, cleaned);
|
||||
return textContent(JSON.stringify({ success: true, report }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('delete_report', 'Delete a report', {
|
||||
report_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
await pb.collection('reports').delete(args.report_id);
|
||||
return textContent(JSON.stringify({ success: true, deleted: args.report_id }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { createAdminClient } from '@/lib/pocketbase';
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
function textContent(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
export function registerTagTools(server: McpServer) {
|
||||
server.tool('create_tag', 'Create a new tag', {
|
||||
name: z.string(),
|
||||
color: z.string().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const tag = await pb.collection('tags').create({
|
||||
name: args.name,
|
||||
color: args.color || null,
|
||||
});
|
||||
return textContent(JSON.stringify({ success: true, tag }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_tag', 'Get a tag by ID', {
|
||||
tag_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const tag = await pb.collection('tags').getOne(args.tag_id);
|
||||
return textContent(JSON.stringify({ success: true, tag }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('list_tags', 'List all tags', {
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const page = Math.floor((args.offset || 0) / (args.limit || 50)) + 1;
|
||||
const result = await pb.collection('tags').getList(page, args.limit || 50, {
|
||||
sort: 'name',
|
||||
});
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
tags: result.items,
|
||||
total: result.totalItems,
|
||||
page: result.page,
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('update_tag', 'Update an existing tag', {
|
||||
tag_id: z.string(),
|
||||
name: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const { tag_id, ...updateData } = args;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updateData)) {
|
||||
if (value !== undefined) cleaned[key] = value;
|
||||
}
|
||||
const tag = await pb.collection('tags').update(tag_id, cleaned);
|
||||
return textContent(JSON.stringify({ success: true, tag }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('delete_tag', 'Delete a tag', {
|
||||
tag_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
await pb.collection('tags').delete(args.tag_id);
|
||||
return textContent(JSON.stringify({ success: true, deleted: args.tag_id }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { createPocketBaseClient, createAdminClient } from '@/lib/pocketbase';
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
function textContent(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
export function registerTaskTools(server: McpServer) {
|
||||
server.tool('create_task', 'Create a new task', {
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
||||
due_date: z.string().optional(),
|
||||
project_id: z.string().optional(),
|
||||
milestone_id: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
domain: z.string(),
|
||||
assignee: z.string().optional(),
|
||||
estimate: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const task = await pb.collection('tasks').create({
|
||||
title: args.title,
|
||||
description: args.description || '',
|
||||
status: args.status || 'todo',
|
||||
priority: args.priority || 'medium',
|
||||
due_date: args.due_date || null,
|
||||
project_id: args.project_id || '',
|
||||
milestone_id: args.milestone_id || '',
|
||||
tags: args.tags || [],
|
||||
domain: args.domain,
|
||||
assignee: args.assignee || '',
|
||||
estimate: args.estimate || null,
|
||||
attachments: [],
|
||||
dependencies: [],
|
||||
subtasks: [],
|
||||
});
|
||||
return textContent(JSON.stringify({ success: true, task }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_task', 'Get a task by ID', {
|
||||
task_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const task = await pb.collection('tasks').getOne(args.task_id);
|
||||
return textContent(JSON.stringify({ success: true, task }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('list_tasks', 'List tasks with optional filters', {
|
||||
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
||||
project_id: z.string().optional(),
|
||||
milestone_id: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const filters: string[] = [];
|
||||
if (args.status) filters.push(`status = "${args.status}"`);
|
||||
if (args.priority) filters.push(`priority = "${args.priority}"`);
|
||||
if (args.project_id) filters.push(`project_id = "${args.project_id}"`);
|
||||
if (args.milestone_id) filters.push(`milestone_id = "${args.milestone_id}"`);
|
||||
if (args.domain) filters.push(`domain = "${args.domain}"`);
|
||||
|
||||
const page = Math.floor((args.offset || 0) / (args.limit || 20)) + 1;
|
||||
const result = await pb.collection('tasks').getList(page, args.limit || 20, {
|
||||
filter: filters.join(' && ') || '',
|
||||
sort: '-created',
|
||||
});
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
tasks: result.items,
|
||||
total: result.totalItems,
|
||||
page: result.page,
|
||||
limit: args.limit || 20,
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('update_task', 'Update an existing task', {
|
||||
task_id: z.string(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
||||
due_date: z.string().optional(),
|
||||
project_id: z.string().optional(),
|
||||
milestone_id: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
domain: z.string().optional(),
|
||||
assignee: z.string().optional(),
|
||||
estimate: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const { task_id, ...updateData } = args;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updateData)) {
|
||||
if (value !== undefined) cleaned[key] = value;
|
||||
}
|
||||
const task = await pb.collection('tasks').update(task_id, cleaned);
|
||||
return textContent(JSON.stringify({ success: true, task }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('delete_task', 'Delete a task', {
|
||||
task_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
await pb.collection('tasks').delete(args.task_id);
|
||||
return textContent(JSON.stringify({ success: true, deleted: args.task_id }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('bulk_create_tasks', 'Create multiple tasks at once', {
|
||||
tasks: z.array(z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
||||
due_date: z.string().optional(),
|
||||
project_id: z.string().optional(),
|
||||
domain: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
})),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const created = [];
|
||||
for (const taskData of args.tasks) {
|
||||
const task = await pb.collection('tasks').create({
|
||||
title: taskData.title,
|
||||
description: taskData.description || '',
|
||||
status: taskData.status || 'todo',
|
||||
priority: taskData.priority || 'medium',
|
||||
due_date: taskData.due_date || null,
|
||||
project_id: taskData.project_id || '',
|
||||
domain: taskData.domain,
|
||||
tags: taskData.tags || [],
|
||||
attachments: [],
|
||||
dependencies: [],
|
||||
subtasks: [],
|
||||
});
|
||||
created.push(task);
|
||||
}
|
||||
return textContent(JSON.stringify({ success: true, created, count: created.length }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('bulk_update_tasks', 'Update multiple tasks at once', {
|
||||
updates: z.array(z.object({
|
||||
task_id: z.string(),
|
||||
title: z.string().optional(),
|
||||
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
||||
})),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const updated = [];
|
||||
for (const { task_id, ...data } of args.updates) {
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value !== undefined) cleaned[key] = value;
|
||||
}
|
||||
const task = await pb.collection('tasks').update(task_id, cleaned);
|
||||
updated.push(task);
|
||||
}
|
||||
return textContent(JSON.stringify({ success: true, updated, count: updated.length }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('bulk_delete_tasks', 'Delete multiple tasks at once', {
|
||||
task_ids: z.array(z.string()),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const deleted = [];
|
||||
for (const id of args.task_ids) {
|
||||
await pb.collection('tasks').delete(id);
|
||||
deleted.push(id);
|
||||
}
|
||||
return textContent(JSON.stringify({ success: true, deleted, count: deleted.length }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { createAdminClient } from '@/lib/pocketbase';
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
function textContent(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
export function registerWebhookTools(server: McpServer) {
|
||||
server.tool('create_webhook', 'Create a new webhook', {
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
events: z.array(z.string()),
|
||||
domain: z.string(),
|
||||
secret: z.string().optional(),
|
||||
active: z.boolean().optional(),
|
||||
retry_count: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const webhook = await pb.collection('webhooks').create({
|
||||
name: args.name,
|
||||
url: args.url,
|
||||
events: args.events,
|
||||
domain: args.domain,
|
||||
secret: args.secret || '',
|
||||
active: args.active !== undefined ? args.active : true,
|
||||
retry_count: args.retry_count || 3,
|
||||
});
|
||||
return textContent(JSON.stringify({ success: true, webhook }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('get_webhook', 'Get a webhook by ID', {
|
||||
webhook_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const webhook = await pb.collection('webhooks').getOne(args.webhook_id);
|
||||
return textContent(JSON.stringify({ success: true, webhook }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('list_webhooks', 'List webhooks with optional filters', {
|
||||
domain: z.string().optional(),
|
||||
active: z.boolean().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const filters: string[] = [];
|
||||
if (args.domain) filters.push(`domain = "${args.domain}"`);
|
||||
if (args.active !== undefined) filters.push(`active = ${args.active}`);
|
||||
|
||||
const page = Math.floor((args.offset || 0) / (args.limit || 20)) + 1;
|
||||
const result = await pb.collection('webhooks').getList(page, args.limit || 20, {
|
||||
filter: filters.join(' && ') || '',
|
||||
sort: '-created',
|
||||
});
|
||||
return textContent(JSON.stringify({
|
||||
success: true,
|
||||
webhooks: result.items,
|
||||
total: result.totalItems,
|
||||
page: result.page,
|
||||
limit: args.limit || 20,
|
||||
}));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('update_webhook', 'Update an existing webhook', {
|
||||
webhook_id: z.string(),
|
||||
name: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
events: z.array(z.string()).optional(),
|
||||
domain: z.string().optional(),
|
||||
active: z.boolean().optional(),
|
||||
retry_count: z.number().optional(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
const { webhook_id, ...updateData } = args;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updateData)) {
|
||||
if (value !== undefined) cleaned[key] = value;
|
||||
}
|
||||
const webhook = await pb.collection('webhooks').update(webhook_id, cleaned);
|
||||
return textContent(JSON.stringify({ success: true, webhook }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
server.tool('delete_webhook', 'Delete a webhook', {
|
||||
webhook_id: z.string(),
|
||||
}, async (args) => {
|
||||
try {
|
||||
await pb.collection('webhooks').delete(args.webhook_id);
|
||||
return textContent(JSON.stringify({ success: true, deleted: args.webhook_id }));
|
||||
} catch (error) {
|
||||
return textContent(JSON.stringify({ success: false, error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://localhost:8090';
|
||||
|
||||
/**
|
||||
* Create a PocketBase client instance
|
||||
* @param token - Optional auth token for authenticated requests
|
||||
*/
|
||||
export function createPocketBaseClient(token?: string): PocketBase {
|
||||
const pb = new PocketBase(pocketbaseUrl);
|
||||
if (token) {
|
||||
pb.authStore.save(token, null);
|
||||
}
|
||||
return pb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin token from environment
|
||||
*/
|
||||
export function getAdminToken(): string {
|
||||
return process.env.POCKETBASE_ADMIN_TOKEN || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an admin-authenticated PocketBase client
|
||||
* Used for server-side operations that need admin privileges
|
||||
*/
|
||||
export function createAdminClient(): PocketBase {
|
||||
const pb = new PocketBase(pocketbaseUrl);
|
||||
const adminToken = getAdminToken();
|
||||
if (adminToken) {
|
||||
pb.authStore.save(adminToken, null);
|
||||
}
|
||||
return pb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper to fetch a record by ID
|
||||
*/
|
||||
export async function getRecord<T extends Record<string, unknown>>(
|
||||
collection: string,
|
||||
id: string,
|
||||
token?: string
|
||||
): Promise<T> {
|
||||
const pb = createPocketBaseClient(token);
|
||||
return pb.collection(collection).getOne(id) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper to list records with filters
|
||||
*/
|
||||
export async function listRecords<T extends Record<string, unknown>>(
|
||||
collection: string,
|
||||
options?: {
|
||||
filter?: string;
|
||||
sort?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
token?: string;
|
||||
}
|
||||
): Promise<{ items: T[]; totalItems: number; totalPages: number }> {
|
||||
const pb = createPocketBaseClient(options?.token);
|
||||
const result = await pb.collection(collection).getList(
|
||||
options?.page || 1,
|
||||
options?.perPage || 50,
|
||||
{
|
||||
filter: options?.filter,
|
||||
sort: options?.sort,
|
||||
}
|
||||
);
|
||||
return {
|
||||
items: result.items as unknown as T[],
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper to create a record
|
||||
*/
|
||||
export async function createRecord<T extends Record<string, unknown>>(
|
||||
collection: string,
|
||||
data: Partial<T>,
|
||||
token?: string
|
||||
): Promise<T> {
|
||||
const pb = createPocketBaseClient(token);
|
||||
return pb.collection(collection).create(data) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper to update a record
|
||||
*/
|
||||
export async function updateRecord<T extends Record<string, unknown>>(
|
||||
collection: string,
|
||||
id: string,
|
||||
data: Partial<T>,
|
||||
token?: string
|
||||
): Promise<T> {
|
||||
const pb = createPocketBaseClient(token);
|
||||
return pb.collection(collection).update(id, data) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper to delete a record
|
||||
*/
|
||||
export async function deleteRecord(
|
||||
collection: string,
|
||||
id: string,
|
||||
token?: string
|
||||
): Promise<boolean> {
|
||||
const pb = createPocketBaseClient(token);
|
||||
return pb.collection(collection).delete(id);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file intentionally left minimal.
|
||||
// react-grid-layout types are handled inline in the dashboard page component
|
||||
// due to @types/react-grid-layout's `export =` pattern not playing well
|
||||
// with bundler moduleResolution.
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createAdminClient } from '../pocketbase';
|
||||
import type { Agent, AgentTask } from '@project-e/shared';
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asAgent(record: Record<string, unknown>): Agent {
|
||||
return record as unknown as Agent;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asAgentTask(record: Record<string, unknown>): AgentTask {
|
||||
return record as unknown as AgentTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse @mentions from text content
|
||||
* Matches @agentname pattern
|
||||
*/
|
||||
export function parseMentions(content: string): string[] {
|
||||
const regex = /@(\w+)/g;
|
||||
const mentions: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
mentions.push(match[1].toLowerCase());
|
||||
}
|
||||
|
||||
return [...new Set(mentions)]; // Deduplicate
|
||||
}
|
||||
|
||||
/**
|
||||
* Process @mentions in content and route to agents
|
||||
*/
|
||||
export async function processMentions(
|
||||
content: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
userId: string
|
||||
): Promise<AgentTask[]> {
|
||||
const mentionNames = parseMentions(content);
|
||||
if (mentionNames.length === 0) return [];
|
||||
|
||||
const pb = createAdminClient();
|
||||
const createdTasks: AgentTask[] = [];
|
||||
|
||||
for (const mentionName of mentionNames) {
|
||||
// Find agent by name (case-insensitive)
|
||||
const agentResults = await pb.collection('agents').getFullList({
|
||||
filter: 'status = "active"',
|
||||
});
|
||||
const agents = agentResults.map((r) => asAgent(r as unknown as Record<string, unknown>));
|
||||
|
||||
const agent = agents.find((a) => a.name.toLowerCase() === mentionName);
|
||||
if (!agent) continue;
|
||||
|
||||
// Extract instruction text after the @mention
|
||||
const mentionRegex = new RegExp(`@${mentionName}\\s+(.+?)(?=@\\w+|$)`, 'is');
|
||||
const mentionMatch = content.match(mentionRegex);
|
||||
const instruction = mentionMatch ? mentionMatch[1].trim() : '';
|
||||
|
||||
// Create agent task
|
||||
const agentTaskRecord = await pb.collection('agent_tasks').create({
|
||||
agent_id: agent.id,
|
||||
task_type: 'mention',
|
||||
input: {
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
instruction,
|
||||
user_id: userId,
|
||||
},
|
||||
status: 'pending',
|
||||
});
|
||||
const agentTask = asAgentTask(agentTaskRecord as unknown as Record<string, unknown>);
|
||||
|
||||
createdTasks.push(agentTask);
|
||||
|
||||
// Queue webhook delivery to agent if it has a webhook URL in config
|
||||
const agentConfig = (agent.config || {}) as Record<string, unknown>;
|
||||
const webhookUrl = agentConfig.webhook_url as string | undefined;
|
||||
|
||||
if (webhookUrl && agent.api_key) {
|
||||
await pb.collection('queue_jobs').create({
|
||||
queue: 'agents',
|
||||
type: 'agent_mention',
|
||||
payload: {
|
||||
agent_task_id: agentTask.id,
|
||||
agent_id: agent.id,
|
||||
agent_webhook_url: webhookUrl,
|
||||
agent_api_key: agent.api_key,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
instruction,
|
||||
user_id: userId,
|
||||
},
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
max_attempts: 3,
|
||||
scheduled_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return createdTasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver @mention to agent via webhook
|
||||
*/
|
||||
export async function deliverAgentMention(payload: {
|
||||
agent_task_id: string;
|
||||
agent_id: string;
|
||||
agent_webhook_url: string;
|
||||
agent_api_key: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
instruction: string;
|
||||
user_id: string;
|
||||
}): Promise<{ success: boolean; statusCode?: number; responseBody?: string }> {
|
||||
const { agent_webhook_url, agent_api_key, ...body } = payload;
|
||||
|
||||
try {
|
||||
const response = await fetch(agent_webhook_url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${agent_api_key}`,
|
||||
'X-Agent-Task-Id': payload.agent_task_id,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000), // 30 second timeout for agent work
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
statusCode: response.status,
|
||||
responseBody,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
responseBody: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { createPocketBaseClient, createAdminClient } from '../pocketbase';
|
||||
import type { Habit, HabitLog, HabitScoreConfig } from '@project-e/shared';
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asHabit(record: Record<string, unknown>): Habit {
|
||||
return record as unknown as Habit;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asHabitLog(record: Record<string, unknown>): HabitLog {
|
||||
return record as unknown as HabitLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default habit score weights
|
||||
*/
|
||||
const DEFAULT_SCORE_CONFIG: Required<HabitScoreConfig> = {
|
||||
streak_weight: 0.5,
|
||||
consistency_weight: 0.2,
|
||||
difficulty_weight: 0.3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Difficulty multipliers
|
||||
*/
|
||||
const DIFFICULTY_MULTIPLIER: Record<string, number> = {
|
||||
easy: 0.7,
|
||||
medium: 1.0,
|
||||
hard: 1.3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate habit score (0-100)
|
||||
* Weighted composite: streak, consistency, difficulty
|
||||
*/
|
||||
export function calculateHabitScore(
|
||||
habit: Habit,
|
||||
logs: HabitLog[],
|
||||
scoreConfig?: HabitScoreConfig
|
||||
): number {
|
||||
const config = { ...DEFAULT_SCORE_CONFIG, ...scoreConfig };
|
||||
|
||||
// 1. Streak score (0-100)
|
||||
const streakScore = Math.min((habit.current_streak / 30) * 100, 100);
|
||||
|
||||
// 2. Consistency rate (last 30 days)
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const recentLogs = logs.filter(
|
||||
(l) => new Date(l.logged_at) >= thirtyDaysAgo && l.completed && !l.skipped
|
||||
);
|
||||
const consistencyScore = (recentLogs.length / 30) * 100;
|
||||
|
||||
// 3. Difficulty multiplier
|
||||
const difficultyMultiplier = DIFFICULTY_MULTIPLIER[habit.difficulty] || 1.0;
|
||||
const difficultyScore = difficultyMultiplier * 100;
|
||||
|
||||
// Weighted composite
|
||||
const score = Math.round(
|
||||
streakScore * config.streak_weight +
|
||||
consistencyScore * config.consistency_weight +
|
||||
difficultyScore * config.difficulty_weight
|
||||
);
|
||||
|
||||
return Math.min(Math.max(score, 0), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update streaks after habit completion
|
||||
*/
|
||||
export function updateStreaks(
|
||||
habit: Habit,
|
||||
completionDate: string
|
||||
): {
|
||||
current_streak: number;
|
||||
best_streak: number;
|
||||
} {
|
||||
const today = new Date(completionDate);
|
||||
const lastCompleted = habit.updated ? new Date(habit.updated) : null;
|
||||
|
||||
let newStreak = habit.current_streak;
|
||||
|
||||
if (lastCompleted) {
|
||||
const daysDiff = Math.floor(
|
||||
(today.getTime() - lastCompleted.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysDiff === 1) {
|
||||
// Consecutive day — increment streak
|
||||
newStreak = habit.current_streak + 1;
|
||||
} else if (daysDiff === 0) {
|
||||
// Same day — no change (already completed today)
|
||||
newStreak = habit.current_streak;
|
||||
} else {
|
||||
// Streak broken — reset to 1
|
||||
newStreak = 1;
|
||||
}
|
||||
} else {
|
||||
// First completion
|
||||
newStreak = 1;
|
||||
}
|
||||
|
||||
return {
|
||||
current_streak: newStreak,
|
||||
best_streak: Math.max(newStreak, habit.best_streak),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log habit completion
|
||||
*/
|
||||
export async function logHabitCompletion(
|
||||
habitId: string,
|
||||
data: {
|
||||
logged_at?: string;
|
||||
mood?: number;
|
||||
value?: number;
|
||||
notes?: string;
|
||||
},
|
||||
token?: string
|
||||
): Promise<{ log: HabitLog; habit: Habit }> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
const loggedAt = data.logged_at || new Date().toISOString();
|
||||
|
||||
// Create log entry
|
||||
const log = asHabitLog(
|
||||
await pb.collection('habit_logs').create({
|
||||
habit_id: habitId,
|
||||
completed: true,
|
||||
mood: data.mood || undefined,
|
||||
value: data.value || undefined,
|
||||
notes: data.notes || '',
|
||||
logged_at: loggedAt,
|
||||
}) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
// Get current habit state
|
||||
const habit = asHabit(
|
||||
await pb.collection('habits').getOne(habitId) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
// Update streaks
|
||||
const streaks = updateStreaks(habit, loggedAt);
|
||||
|
||||
// Get all logs for score calculation
|
||||
const allLogsResult = await pb.collection('habit_logs').getFullList({
|
||||
filter: `habit_id = "${habitId}"`,
|
||||
});
|
||||
const allLogs = allLogsResult.map((r) => asHabitLog(r as unknown as Record<string, unknown>));
|
||||
|
||||
// Calculate new score
|
||||
const score = calculateHabitScore(
|
||||
habit,
|
||||
allLogs,
|
||||
habit.score_config as HabitScoreConfig
|
||||
);
|
||||
|
||||
// Update habit
|
||||
const updatedHabit = asHabit(
|
||||
await pb.collection('habits').update(habitId, {
|
||||
current_streak: streaks.current_streak,
|
||||
best_streak: streaks.best_streak,
|
||||
total_completions: habit.total_completions + 1,
|
||||
score,
|
||||
}) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
return { log, habit: updatedHabit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a habit is due today
|
||||
*/
|
||||
export function isHabitDueToday(habit: Habit): boolean {
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay(); // 0 = Sunday, 6 = Saturday
|
||||
|
||||
// Check custom_days if set
|
||||
if (habit.custom_days && habit.custom_days.length > 0) {
|
||||
return habit.custom_days.includes(dayOfWeek);
|
||||
}
|
||||
|
||||
// Check frequency
|
||||
switch (habit.frequency) {
|
||||
case 'daily':
|
||||
return true;
|
||||
case 'weekly': {
|
||||
// Due on the same day of week as start_date
|
||||
const startDate = habit.start_date
|
||||
? new Date(habit.start_date)
|
||||
: new Date(habit.created);
|
||||
return dayOfWeek === startDate.getDay();
|
||||
}
|
||||
case 'custom':
|
||||
// Custom frequency without custom_days — assume always due
|
||||
return true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get habits due today
|
||||
*/
|
||||
export async function getHabitsDueToday(
|
||||
token?: string
|
||||
): Promise<Habit[]> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
const results = await pb.collection('habits').getFullList({
|
||||
filter: 'active = true',
|
||||
});
|
||||
|
||||
return results
|
||||
.map((r) => asHabit(r as unknown as Record<string, unknown>))
|
||||
.filter((habit) => isHabitDueToday(habit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get habit streaks summary
|
||||
*/
|
||||
export async function getHabitStreaks(
|
||||
token?: string
|
||||
): Promise<
|
||||
Array<{
|
||||
habit: Habit;
|
||||
current_streak: number;
|
||||
best_streak: number;
|
||||
}>
|
||||
> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
const results = await pb.collection('habits').getFullList({
|
||||
filter: 'active = true',
|
||||
});
|
||||
|
||||
const habits = results.map((r) => asHabit(r as unknown as Record<string, unknown>));
|
||||
|
||||
return habits
|
||||
.map((h) => ({
|
||||
habit: h,
|
||||
current_streak: h.current_streak,
|
||||
best_streak: h.best_streak,
|
||||
}))
|
||||
.sort((a, b) => b.current_streak - a.current_streak);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './task-service';
|
||||
export * from './habit-service';
|
||||
export * from './project-service';
|
||||
export * from './note-service';
|
||||
export * from './report-service';
|
||||
export * from './webhook-service';
|
||||
export * from './agent-mention-service';
|
||||
@@ -0,0 +1,307 @@
|
||||
import { createPocketBaseClient, createAdminClient } from '../pocketbase';
|
||||
import type { Note, NoteLink, NoteTaskLink, Task } from '@project-e/shared';
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asNote(record: Record<string, unknown>): Note {
|
||||
return record as unknown as Note;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asNoteLink(record: Record<string, unknown>): NoteLink {
|
||||
return record as unknown as NoteLink;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asNoteTaskLink(record: Record<string, unknown>): NoteTaskLink {
|
||||
return record as unknown as NoteTaskLink;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asTask(record: Record<string, unknown>): Task {
|
||||
return record as unknown as Task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract wikilinks from note content
|
||||
* Matches [[Note Title]] syntax
|
||||
*/
|
||||
export function extractWikilinks(content: string): string[] {
|
||||
const regex = /\[\[([^\]]+)\]\]/g;
|
||||
const links: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
links.push(match[1].trim());
|
||||
}
|
||||
|
||||
return [...new Set(links)]; // Deduplicate
|
||||
}
|
||||
|
||||
/**
|
||||
* Update note_links when a note is saved
|
||||
* Re-syncs all outbound wikilinks from the note content
|
||||
*/
|
||||
export async function syncNoteLinks(
|
||||
noteId: string,
|
||||
content: string,
|
||||
token?: string
|
||||
): Promise<void> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Extract wikilinks from content
|
||||
const linkTitles = extractWikilinks(content);
|
||||
|
||||
// Get existing links from this note
|
||||
const existingResults = await pb.collection('note_links').getFullList({
|
||||
filter: `source_note_id = "${noteId}"`,
|
||||
});
|
||||
const existingLinks = existingResults.map((r) =>
|
||||
asNoteLink(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Delete existing links
|
||||
for (const link of existingLinks) {
|
||||
await pb.collection('note_links').delete(link.id);
|
||||
}
|
||||
|
||||
// Create new links
|
||||
for (const title of linkTitles) {
|
||||
// Find target note by title
|
||||
const targetNotes = await pb.collection('notes').getFullList({
|
||||
filter: `title = "${title}"`,
|
||||
});
|
||||
|
||||
if (targetNotes.length > 0) {
|
||||
await pb.collection('note_links').create({
|
||||
source_note_id: noteId,
|
||||
target_note_id: targetNotes[0].id,
|
||||
label: title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backlinks for a note (notes that link TO this note)
|
||||
*/
|
||||
export async function getBacklinks(
|
||||
noteId: string,
|
||||
token?: string
|
||||
): Promise<Note[]> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Find all note_links where this note is the target
|
||||
const linkResults = await pb.collection('note_links').getFullList({
|
||||
filter: `target_note_id = "${noteId}"`,
|
||||
});
|
||||
const links = linkResults.map((r) =>
|
||||
asNoteLink(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Get the source notes
|
||||
const backlinks: Note[] = [];
|
||||
for (const link of links) {
|
||||
const note = asNote(
|
||||
await pb
|
||||
.collection('notes')
|
||||
.getOne(link.source_note_id) as unknown as Record<string, unknown>
|
||||
);
|
||||
backlinks.push(note);
|
||||
}
|
||||
|
||||
return backlinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync checkboxes in note content with tasks
|
||||
* Maps checkboxes to tasks via note_task_links
|
||||
*/
|
||||
export async function syncNoteTasks(
|
||||
noteId: string,
|
||||
content: string,
|
||||
token?: string
|
||||
): Promise<void> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Extract checkbox patterns: - [ ] Task title or - [x] Task title
|
||||
const checkboxRegex = /^- \[([ x])\] (.+)$/gm;
|
||||
const checkboxes: Array<{
|
||||
done: boolean;
|
||||
title: string;
|
||||
position: number;
|
||||
}> = [];
|
||||
let match;
|
||||
let position = 0;
|
||||
|
||||
while ((match = checkboxRegex.exec(content)) !== null) {
|
||||
checkboxes.push({
|
||||
done: match[1] === 'x',
|
||||
title: match[2].trim(),
|
||||
position: position++,
|
||||
});
|
||||
}
|
||||
|
||||
// Get existing note_task_links
|
||||
const existingResults = await pb.collection('note_task_links').getFullList({
|
||||
filter: `note_id = "${noteId}"`,
|
||||
});
|
||||
const existingLinks = existingResults.map((r) =>
|
||||
asNoteTaskLink(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Match checkboxes to existing links by index position
|
||||
for (let i = 0; i < checkboxes.length; i++) {
|
||||
const checkbox = checkboxes[i];
|
||||
const existingLink = existingLinks.find(
|
||||
(l) => l.label === `checkbox_${i}` || (!l.label && existingLinks.indexOf(l) === i)
|
||||
);
|
||||
|
||||
if (existingLink) {
|
||||
// Update existing task
|
||||
const task = asTask(
|
||||
await pb
|
||||
.collection('tasks')
|
||||
.getOne(existingLink.task_id) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
// Update title if changed
|
||||
if (task.title !== checkbox.title) {
|
||||
await pb.collection('tasks').update(existingLink.task_id, {
|
||||
title: checkbox.title,
|
||||
});
|
||||
}
|
||||
|
||||
// Update done status if changed
|
||||
const taskDone = task.status === 'done';
|
||||
if (taskDone !== checkbox.done) {
|
||||
await pb.collection('tasks').update(existingLink.task_id, {
|
||||
status: checkbox.done ? 'done' : 'todo',
|
||||
...(checkbox.done && { completed_at: new Date().toISOString() }),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create new task for this checkbox
|
||||
const note = asNote(
|
||||
await pb
|
||||
.collection('notes')
|
||||
.getOne(noteId) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
const newTask = await pb.collection('tasks').create({
|
||||
title: checkbox.title,
|
||||
description: '',
|
||||
status: checkbox.done ? 'done' : 'todo',
|
||||
priority: 'medium',
|
||||
domain: note.domain,
|
||||
tags: [],
|
||||
...(checkbox.done && { completed_at: new Date().toISOString() }),
|
||||
});
|
||||
|
||||
// Create mapping
|
||||
await pb.collection('note_task_links').create({
|
||||
note_id: noteId,
|
||||
task_id: newTask.id,
|
||||
label: `checkbox_${i}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove links for checkboxes that no longer exist
|
||||
for (const link of existingLinks) {
|
||||
const matchIndex = parseInt(
|
||||
(link.label || '').replace('checkbox_', ''),
|
||||
10
|
||||
);
|
||||
if (isNaN(matchIndex) || matchIndex >= checkboxes.length) {
|
||||
await pb.collection('note_task_links').delete(link.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse frontmatter from note content
|
||||
* Frontmatter is YAML between --- delimiters at the top
|
||||
*/
|
||||
export function parseFrontmatter(content: string): {
|
||||
frontmatter: Record<string, unknown>;
|
||||
contentWithoutFrontmatter: string;
|
||||
} {
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { frontmatter: {}, contentWithoutFrontmatter: content };
|
||||
}
|
||||
|
||||
// Simple YAML parsing (key: value pairs)
|
||||
const yamlText = match[1];
|
||||
const frontmatter: Record<string, unknown> = {};
|
||||
|
||||
for (const line of yamlText.split('\n')) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const value = line.slice(colonIndex + 1).trim();
|
||||
|
||||
// Try to parse as JSON for arrays/objects
|
||||
try {
|
||||
frontmatter[key] = JSON.parse(value);
|
||||
} catch {
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
contentWithoutFrontmatter: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get graph data for note visualization
|
||||
*/
|
||||
export async function getNoteGraph(
|
||||
token?: string
|
||||
): Promise<{
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
domain: string;
|
||||
connectionCount: number;
|
||||
}>;
|
||||
edges: Array<{ source: string; target: string }>;
|
||||
}> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
const noteResults = await pb.collection('notes').getFullList();
|
||||
const notes = noteResults.map((r) => asNote(r as unknown as Record<string, unknown>));
|
||||
|
||||
const linkResults = await pb.collection('note_links').getFullList();
|
||||
const links = linkResults.map((r) =>
|
||||
asNoteLink(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Count connections per note
|
||||
const connectionCounts: Record<string, number> = {};
|
||||
for (const link of links) {
|
||||
connectionCounts[link.source_note_id] =
|
||||
(connectionCounts[link.source_note_id] || 0) + 1;
|
||||
connectionCounts[link.target_note_id] =
|
||||
(connectionCounts[link.target_note_id] || 0) + 1;
|
||||
}
|
||||
|
||||
const nodes = notes.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
domain: n.domain,
|
||||
connectionCount: connectionCounts[n.id] || 0,
|
||||
}));
|
||||
|
||||
const edges = links.map((l) => ({
|
||||
source: l.source_note_id,
|
||||
target: l.target_note_id,
|
||||
}));
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createPocketBaseClient, createAdminClient } from '../pocketbase';
|
||||
import type { Project, Task, Milestone } from '@project-e/shared';
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asProject(record: Record<string, unknown>): Project {
|
||||
return record as unknown as Project;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asTask(record: Record<string, unknown>): Task {
|
||||
return record as unknown as Task;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asMilestone(record: Record<string, unknown>): Milestone {
|
||||
return record as unknown as Milestone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute project progress from task completion percentage
|
||||
* Returns the percentage but does not persist it (Project schema has no progress field)
|
||||
*/
|
||||
export async function computeProjectProgress(
|
||||
projectId: string,
|
||||
token?: string
|
||||
): Promise<number> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Get all tasks for this project
|
||||
const results = await pb.collection('tasks').getFullList({
|
||||
filter: `project_id = "${projectId}"`,
|
||||
});
|
||||
const tasks = results.map((r) => asTask(r as unknown as Record<string, unknown>));
|
||||
|
||||
if (tasks.length === 0) return 0;
|
||||
|
||||
const done = tasks.filter((t) => t.status === 'done').length;
|
||||
return Math.round((done / tasks.length) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project with computed progress and task counts
|
||||
*/
|
||||
export async function getProjectWithProgress(
|
||||
projectId: string,
|
||||
token?: string
|
||||
): Promise<Project & { taskCount: number; doneCount: number; progress: number }> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
const project = asProject(
|
||||
await pb.collection('projects').getOne(projectId) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
const results = await pb.collection('tasks').getFullList({
|
||||
filter: `project_id = "${projectId}"`,
|
||||
});
|
||||
const tasks = results.map((r) => asTask(r as unknown as Record<string, unknown>));
|
||||
|
||||
const doneCount = tasks.filter((t) => t.status === 'done').length;
|
||||
const progress = tasks.length > 0 ? Math.round((doneCount / tasks.length) * 100) : 0;
|
||||
|
||||
return {
|
||||
...project,
|
||||
taskCount: tasks.length,
|
||||
doneCount,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check milestone dependency enforcement
|
||||
*/
|
||||
export async function canStartMilestone(
|
||||
milestoneId: string,
|
||||
token?: string
|
||||
): Promise<{ allowed: boolean; blockedBy: string[] }> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Get the milestone with its embedded dependencies
|
||||
const milestone = asMilestone(
|
||||
await pb.collection('milestones').getOne(milestoneId) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
const blockedBy: string[] = [];
|
||||
|
||||
for (const dep of milestone.dependencies || []) {
|
||||
const depMilestone = asMilestone(
|
||||
await pb
|
||||
.collection('milestones')
|
||||
.getOne(dep.depends_on_id) as unknown as Record<string, unknown>
|
||||
);
|
||||
if (depMilestone.status !== 'complete') {
|
||||
blockedBy.push(dep.depends_on_id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: blockedBy.length === 0,
|
||||
blockedBy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get milestone timeline for a project
|
||||
*/
|
||||
export async function getMilestoneTimeline(
|
||||
projectId: string,
|
||||
token?: string
|
||||
): Promise<Milestone[]> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
const results = await pb.collection('milestones').getFullList({
|
||||
filter: `project_id = "${projectId}"`,
|
||||
sort: 'sort_order',
|
||||
});
|
||||
|
||||
return results.map((r) => asMilestone(r as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log milestone status change to milestone_history
|
||||
*/
|
||||
export async function logMilestoneStatusChange(
|
||||
milestoneId: string,
|
||||
field: string,
|
||||
oldValue: string | undefined,
|
||||
newValue: string,
|
||||
changedBy?: string,
|
||||
token?: string
|
||||
): Promise<void> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
await pb.collection('milestone_history').create({
|
||||
milestone_id: milestoneId,
|
||||
field,
|
||||
old_value: oldValue,
|
||||
new_value: newValue,
|
||||
changed_by: changedBy,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { createAdminClient } from '../pocketbase';
|
||||
import type {
|
||||
Task,
|
||||
Habit,
|
||||
HabitLog,
|
||||
Milestone,
|
||||
TimeEntry,
|
||||
} from '@project-e/shared';
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asTask(record: Record<string, unknown>): Task {
|
||||
return record as unknown as Task;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asHabit(record: Record<string, unknown>): Habit {
|
||||
return record as unknown as Habit;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asHabitLog(record: Record<string, unknown>): HabitLog {
|
||||
return record as unknown as HabitLog;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asMilestone(record: Record<string, unknown>): Milestone {
|
||||
return record as unknown as Milestone;
|
||||
}
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asTimeEntry(record: Record<string, unknown>): TimeEntry {
|
||||
return record as unknown as TimeEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate weekly summary report data
|
||||
*/
|
||||
export async function generateWeeklySummary(
|
||||
weekStart: string,
|
||||
weekEnd: string,
|
||||
token?: string
|
||||
): Promise<{
|
||||
tasksCompleted: number;
|
||||
habitsTracked: number;
|
||||
timeLogged: number;
|
||||
streaks: Array<{ name: string; streak: number }>;
|
||||
byDomain: Record<string, { tasks: number; habits: number }>;
|
||||
}> {
|
||||
const pb = token ? createAdminClient() : createAdminClient();
|
||||
|
||||
// Tasks completed this week
|
||||
const taskResults = await pb.collection('tasks').getFullList({
|
||||
filter: `status = "done" && completed_at >= "${weekStart}" && completed_at <= "${weekEnd}"`,
|
||||
});
|
||||
const tasks = taskResults.map((r) => asTask(r as unknown as Record<string, unknown>));
|
||||
|
||||
// Habits logged this week
|
||||
const habitLogResults = await pb.collection('habit_logs').getFullList({
|
||||
filter: `logged_at >= "${weekStart}" && logged_at <= "${weekEnd}"`,
|
||||
});
|
||||
const habitLogs = habitLogResults.map((r) =>
|
||||
asHabitLog(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Time entries this week
|
||||
const timeEntryResults = await pb.collection('time_entries').getFullList({
|
||||
filter: `started_at >= "${weekStart}" && started_at <= "${weekEnd}"`,
|
||||
});
|
||||
const timeEntries = timeEntryResults.map((r) =>
|
||||
asTimeEntry(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
const totalTime = timeEntries.reduce(
|
||||
(sum, e) => sum + e.duration_minutes,
|
||||
0
|
||||
);
|
||||
|
||||
// Streaks
|
||||
const habitResults = await pb.collection('habits').getFullList();
|
||||
const habits = habitResults.map((r) => asHabit(r as unknown as Record<string, unknown>));
|
||||
const streaks = habits
|
||||
.map((h) => ({ name: h.name, streak: h.current_streak }))
|
||||
.sort((a, b) => b.streak - a.streak);
|
||||
|
||||
// By domain
|
||||
const byDomain: Record<string, { tasks: number; habits: number }> = {};
|
||||
for (const task of tasks) {
|
||||
if (!byDomain[task.domain])
|
||||
byDomain[task.domain] = { tasks: 0, habits: 0 };
|
||||
byDomain[task.domain].tasks++;
|
||||
}
|
||||
|
||||
return {
|
||||
tasksCompleted: tasks.length,
|
||||
habitsTracked: habitLogs.length,
|
||||
timeLogged: totalTime,
|
||||
streaks,
|
||||
byDomain,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate project health report data
|
||||
*/
|
||||
export async function generateProjectHealth(
|
||||
projectId: string,
|
||||
token?: string
|
||||
): Promise<{
|
||||
taskCount: number;
|
||||
doneCount: number;
|
||||
overdueCount: number;
|
||||
milestoneStatus: Record<string, number>;
|
||||
completionRate: number;
|
||||
}> {
|
||||
const pb = token ? createAdminClient() : createAdminClient();
|
||||
|
||||
const taskResults = await pb.collection('tasks').getFullList({
|
||||
filter: `project_id = "${projectId}"`,
|
||||
});
|
||||
const tasks = taskResults.map((r) => asTask(r as unknown as Record<string, unknown>));
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const doneCount = tasks.filter((t) => t.status === 'done').length;
|
||||
const overdueCount = tasks.filter(
|
||||
(t) => t.due_date && t.due_date < today && t.status !== 'done'
|
||||
).length;
|
||||
|
||||
// Milestone status
|
||||
const milestoneResults = await pb.collection('milestones').getFullList({
|
||||
filter: `project_id = "${projectId}"`,
|
||||
});
|
||||
const milestones = milestoneResults.map((r) =>
|
||||
asMilestone(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
const milestoneStatus: Record<string, number> = {
|
||||
planned: 0,
|
||||
in_progress: 0,
|
||||
complete: 0,
|
||||
};
|
||||
for (const m of milestones) {
|
||||
milestoneStatus[m.status] = (milestoneStatus[m.status] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
taskCount: tasks.length,
|
||||
doneCount,
|
||||
overdueCount,
|
||||
milestoneStatus,
|
||||
completionRate:
|
||||
tasks.length > 0 ? Math.round((doneCount / tasks.length) * 100) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate habit analysis report data
|
||||
*/
|
||||
export async function generateHabitAnalysis(
|
||||
days: number = 30,
|
||||
token?: string
|
||||
): Promise<{
|
||||
habits: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
streak: number;
|
||||
score: number;
|
||||
consistencyRate: number;
|
||||
completions: number;
|
||||
}>;
|
||||
atRiskHabits: string[];
|
||||
}> {
|
||||
const pb = token ? createAdminClient() : createAdminClient();
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
const startStr = startDate.toISOString().split('T')[0];
|
||||
|
||||
const habitResults = await pb.collection('habits').getFullList();
|
||||
const habits = habitResults.map((r) => asHabit(r as unknown as Record<string, unknown>));
|
||||
|
||||
const habitAnalysis = [];
|
||||
const atRiskHabits: string[] = [];
|
||||
|
||||
for (const habit of habits) {
|
||||
const logResults = await pb.collection('habit_logs').getFullList({
|
||||
filter: `habit_id = "${habit.id}" && logged_at >= "${startStr}" && completed = true && skipped = false`,
|
||||
});
|
||||
const logs = logResults.map((r) =>
|
||||
asHabitLog(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
const consistencyRate = Math.round((logs.length / days) * 100);
|
||||
|
||||
habitAnalysis.push({
|
||||
id: habit.id,
|
||||
name: habit.name,
|
||||
streak: habit.current_streak,
|
||||
score: habit.score,
|
||||
consistencyRate,
|
||||
completions: logs.length,
|
||||
});
|
||||
|
||||
// At risk: consistency < 50% or streak broken
|
||||
if (consistencyRate < 50 || habit.current_streak === 0) {
|
||||
atRiskHabits.push(habit.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
habits: habitAnalysis.sort((a, b) => b.score - a.score),
|
||||
atRiskHabits,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate time audit report data
|
||||
*/
|
||||
export async function generateTimeAudit(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
token?: string
|
||||
): Promise<{
|
||||
totalMinutes: number;
|
||||
byDomain: Record<string, number>;
|
||||
byProject: Record<string, number>;
|
||||
byTag: Record<string, number>;
|
||||
}> {
|
||||
const pb = token ? createAdminClient() : createAdminClient();
|
||||
|
||||
const entryResults = await pb.collection('time_entries').getFullList({
|
||||
filter: `started_at >= "${startDate}" && started_at <= "${endDate}"`,
|
||||
});
|
||||
const entries = entryResults.map((r) =>
|
||||
asTimeEntry(r as unknown as Record<string, unknown>)
|
||||
);
|
||||
|
||||
const byDomain: Record<string, number> = {};
|
||||
const byProject: Record<string, number> = {};
|
||||
const byTag: Record<string, number> = {};
|
||||
let totalMinutes = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
totalMinutes += entry.duration_minutes;
|
||||
|
||||
// Get the entity to access domain/project/tags
|
||||
// TimeEntry uses entity_type and entity_id
|
||||
if (entry.entity_type === 'task') {
|
||||
const task = asTask(
|
||||
await pb
|
||||
.collection('tasks')
|
||||
.getOne(entry.entity_id) as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
byDomain[task.domain] =
|
||||
(byDomain[task.domain] || 0) + entry.duration_minutes;
|
||||
|
||||
if (task.project_id) {
|
||||
byProject[task.project_id] =
|
||||
(byProject[task.project_id] || 0) + entry.duration_minutes;
|
||||
}
|
||||
|
||||
for (const tag of task.tags || []) {
|
||||
byTag[tag] = (byTag[tag] || 0) + entry.duration_minutes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { totalMinutes, byDomain, byProject, byTag };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createPocketBaseClient, createAdminClient } from '../pocketbase';
|
||||
import type { Task, Subtask } from '@project-e/shared';
|
||||
|
||||
/** Cast a PocketBase RecordModel to a typed domain model */
|
||||
function asTask(record: Record<string, unknown>): Task {
|
||||
return record as unknown as Task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate task progress from subtask completion ratio
|
||||
*/
|
||||
export function calculateTaskProgress(subtasks: Subtask[]): number {
|
||||
if (subtasks.length === 0) return 0;
|
||||
const done = subtasks.filter((s) => s.done).length;
|
||||
return Math.round((done / subtasks.length) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task is blocked by dependencies
|
||||
* Returns array of blocking task IDs
|
||||
*/
|
||||
export async function getBlockingDependencies(
|
||||
taskId: string,
|
||||
dependencies: string[]
|
||||
): Promise<string[]> {
|
||||
if (dependencies.length === 0) return [];
|
||||
|
||||
const pb = createAdminClient();
|
||||
const blocking: string[] = [];
|
||||
|
||||
for (const depId of dependencies) {
|
||||
const depTask = asTask(
|
||||
await pb.collection('tasks').getOne(depId)
|
||||
);
|
||||
if (depTask.status !== 'done') {
|
||||
blocking.push(depId);
|
||||
}
|
||||
}
|
||||
|
||||
return blocking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check dependency gating — can this task be started?
|
||||
*/
|
||||
export async function canStartTask(
|
||||
taskId: string,
|
||||
dependencies: string[]
|
||||
): Promise<{ allowed: boolean; blockedBy: string[] }> {
|
||||
const blockedBy = await getBlockingDependencies(taskId, dependencies);
|
||||
return {
|
||||
allowed: blockedBy.length === 0,
|
||||
blockedBy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task completion — trigger recurring task spawn if needed
|
||||
*/
|
||||
export async function completeTask(
|
||||
taskId: string,
|
||||
token?: string
|
||||
): Promise<{ task: Task; nextRecurringTaskId?: string }> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Mark task as done
|
||||
const task = asTask(
|
||||
await pb.collection('tasks').update(taskId, {
|
||||
status: 'done',
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
let nextRecurringTaskId: string | undefined;
|
||||
|
||||
// If recurring, spawn next occurrence
|
||||
if (task.recurring_config?.rule) {
|
||||
nextRecurringTaskId = await spawnNextRecurringTask(task);
|
||||
}
|
||||
|
||||
return { task, nextRecurringTaskId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn next recurring task from RRULE
|
||||
*/
|
||||
async function spawnNextRecurringTask(task: Task): Promise<string> {
|
||||
// Import rrule dynamically to avoid bundling issues
|
||||
const { RRule } = await import('rrule');
|
||||
|
||||
const rule = RRule.fromString(task.recurring_config!.rule);
|
||||
const now = new Date();
|
||||
const nextDate = rule.after(now, true);
|
||||
|
||||
if (!nextDate) {
|
||||
throw new Error('No next occurrence found for recurring task');
|
||||
}
|
||||
|
||||
const pb = createAdminClient();
|
||||
|
||||
// Create next occurrence
|
||||
const nextTask = await pb.collection('tasks').create({
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
status: 'todo',
|
||||
priority: task.priority,
|
||||
due_date: nextDate.toISOString(),
|
||||
project_id: task.project_id || '',
|
||||
milestone_id: task.milestone_id || '',
|
||||
tags: task.tags || [],
|
||||
domain: task.domain,
|
||||
estimate: task.estimate || null,
|
||||
recurring_config: task.recurring_config,
|
||||
dependencies: task.dependencies || [],
|
||||
custom_fields: task.custom_fields || {},
|
||||
});
|
||||
|
||||
return nextTask.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a subtask to a full task
|
||||
*/
|
||||
export async function promoteSubtask(
|
||||
parentTaskId: string,
|
||||
subtaskId: string,
|
||||
token?: string
|
||||
): Promise<Task> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
|
||||
// Get parent task
|
||||
const parentTask = asTask(
|
||||
await pb.collection('tasks').getOne(parentTaskId)
|
||||
);
|
||||
|
||||
// Find the subtask
|
||||
const subtasks = (parentTask.subtasks || []) as Subtask[];
|
||||
const subtask = subtasks.find((s) => s.id === subtaskId);
|
||||
|
||||
if (!subtask) {
|
||||
throw new Error('Subtask not found');
|
||||
}
|
||||
|
||||
// Create new task from subtask
|
||||
const newTask = await pb.collection('tasks').create({
|
||||
title: subtask.title,
|
||||
description: '',
|
||||
status: subtask.done ? 'done' : 'todo',
|
||||
priority: parentTask.priority,
|
||||
project_id: parentTask.project_id || '',
|
||||
domain: parentTask.domain,
|
||||
tags: parentTask.tags || [],
|
||||
});
|
||||
|
||||
// Remove subtask from parent
|
||||
const updatedSubtasks = subtasks.filter((s) => s.id !== subtaskId);
|
||||
await pb.collection('tasks').update(parentTaskId, {
|
||||
subtasks: updatedSubtasks,
|
||||
});
|
||||
|
||||
return asTask(newTask as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-compute parent task progress from subtask completion
|
||||
*/
|
||||
export async function updateTaskProgress(
|
||||
taskId: string,
|
||||
token?: string
|
||||
): Promise<number> {
|
||||
const pb = token ? createPocketBaseClient(token) : createAdminClient();
|
||||
const task = asTask(await pb.collection('tasks').getOne(taskId));
|
||||
|
||||
const subtasks = (task.subtasks || []) as Subtask[];
|
||||
return calculateTaskProgress(subtasks);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createAdminClient } from '../pocketbase';
|
||||
import { eventBus, EVENTS } from '../events/event-bus';
|
||||
import type { Webhook } from '@project-e/shared';
|
||||
|
||||
/**
|
||||
* Initialize webhook service — subscribe to all events
|
||||
* Call this once at app startup
|
||||
*/
|
||||
export function initializeWebhookService(): void {
|
||||
// Subscribe to all domain events
|
||||
eventBus.on(EVENTS.TASK_COMPLETED, (data) => {
|
||||
queueWebhookDelivery('task.completed', data);
|
||||
});
|
||||
|
||||
eventBus.on(EVENTS.HABIT_COMPLETED, (data) => {
|
||||
queueWebhookDelivery('habit.completed', data);
|
||||
});
|
||||
|
||||
eventBus.on(EVENTS.HABIT_STREAK_BROKEN, (data) => {
|
||||
queueWebhookDelivery('habit.streak_broken', data);
|
||||
});
|
||||
|
||||
eventBus.on(EVENTS.MILESTONE_REACHED, (data) => {
|
||||
queueWebhookDelivery('milestone.reached', data);
|
||||
});
|
||||
|
||||
eventBus.on(EVENTS.PROJECT_STATUS_CHANGED, (data) => {
|
||||
queueWebhookDelivery('project.status_changed', data);
|
||||
});
|
||||
|
||||
eventBus.on(EVENTS.REPORT_GENERATED, (data) => {
|
||||
queueWebhookDelivery('report.generated', data);
|
||||
});
|
||||
|
||||
eventBus.on(EVENTS.AGENT_TASK_COMPLETED, (data) => {
|
||||
queueWebhookDelivery('agent_task.completed', data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a webhook delivery for all matching webhooks
|
||||
*/
|
||||
async function queueWebhookDelivery(eventType: string, payload: unknown): Promise<void> {
|
||||
const pb = createAdminClient();
|
||||
|
||||
try {
|
||||
// Get all active webhooks that subscribe to this event type
|
||||
const webhooks = await pb.collection('webhooks').getFullList({
|
||||
filter: 'active = true',
|
||||
}) as Webhook[];
|
||||
|
||||
const matchingWebhooks = webhooks.filter((webhook) => {
|
||||
const events = webhook.events as string[];
|
||||
return events.includes(eventType) || events.includes('*');
|
||||
});
|
||||
|
||||
// Queue delivery for each matching webhook
|
||||
for (const webhook of matchingWebhooks) {
|
||||
await pb.collection('queue_jobs').create({
|
||||
queue: 'webhooks',
|
||||
type: 'webhook_delivery',
|
||||
payload: {
|
||||
webhook_id: webhook.id,
|
||||
webhook_url: webhook.url,
|
||||
webhook_secret: webhook.secret || '',
|
||||
event_type: eventType,
|
||||
event_payload: payload,
|
||||
},
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
max_attempts: webhook.retry_count || 3,
|
||||
scheduled_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to queue webhook delivery:', error);
|
||||
// Log to error_logs collection
|
||||
await pb.collection('error_logs').create({
|
||||
level: 'error',
|
||||
source: 'webhook-service',
|
||||
message: 'Failed to queue webhook delivery',
|
||||
metadata: { eventType, payload, error: String(error) },
|
||||
}).catch(() => {
|
||||
// Ignore logging errors
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a webhook (called by the worker)
|
||||
*/
|
||||
export async function deliverWebhook(job: {
|
||||
webhook_id: string;
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
event_type: string;
|
||||
event_payload: unknown;
|
||||
}): Promise<{ success: boolean; statusCode?: number; responseBody?: string }> {
|
||||
const { webhook_url, webhook_secret, event_type, event_payload } = job;
|
||||
|
||||
try {
|
||||
// Create HMAC signature if secret is provided
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Event-Type': event_type,
|
||||
};
|
||||
|
||||
if (webhook_secret) {
|
||||
const crypto = await import('node:crypto');
|
||||
const payload = JSON.stringify(event_payload);
|
||||
const signature = crypto
|
||||
.createHmac('sha256', webhook_secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
headers['X-Webhook-Signature'] = signature;
|
||||
}
|
||||
|
||||
const response = await fetch(webhook_url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(event_payload),
|
||||
signal: AbortSignal.timeout(10000), // 10 second timeout
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
statusCode: response.status,
|
||||
responseBody,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
responseBody: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record webhook delivery result
|
||||
*/
|
||||
export async function recordWebhookDelivery(
|
||||
webhookId: string,
|
||||
eventType: string,
|
||||
payload: unknown,
|
||||
result: { success: boolean; statusCode?: number; responseBody?: string },
|
||||
attempts: number
|
||||
): Promise<void> {
|
||||
const pb = createAdminClient();
|
||||
|
||||
await pb.collection('webhook_deliveries').create({
|
||||
webhook_id: webhookId,
|
||||
event: eventType,
|
||||
payload: payload as Record<string, unknown>,
|
||||
status: result.success ? 'success' : 'failed',
|
||||
status_code: result.statusCode || 0,
|
||||
response_body: result.responseBody || '',
|
||||
attempts,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { useThemeStore } from './use-theme-store';
|
||||
export { useSidebarStore } from './use-sidebar-store';
|
||||
export { useDashboardStore } from './use-dashboard-store';
|
||||
export { useTimerStore } from './use-timer-store';
|
||||
export { useFilterStore } from './use-filter-store';
|
||||
export { useKeyboardShortcutsStore } from './use-keyboard-shortcuts-store';
|
||||
@@ -0,0 +1,62 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface WidgetConfig {
|
||||
id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface DashboardState {
|
||||
widgets: WidgetConfig[];
|
||||
|
||||
// Actions
|
||||
setWidgets: (widgets: WidgetConfig[]) => void;
|
||||
updateWidget: (id: string, updates: Partial<WidgetConfig>) => void;
|
||||
addWidget: (widget: WidgetConfig) => void;
|
||||
removeWidget: (id: string) => void;
|
||||
resetLayout: () => void;
|
||||
}
|
||||
|
||||
const defaultWidgets: WidgetConfig[] = [
|
||||
{ id: 'today-tasks', type: 'TodayTasks', x: 0, y: 0, w: 6, h: 4, visible: true },
|
||||
{ id: 'habit-checklist', type: 'HabitChecklist', x: 6, y: 0, w: 3, h: 4, visible: true },
|
||||
{ id: 'weekly-stats', type: 'WeeklyStats', x: 9, y: 0, w: 3, h: 4, visible: true },
|
||||
{ id: 'project-progress', type: 'ProjectProgress', x: 0, y: 4, w: 4, h: 3, visible: true },
|
||||
{ id: 'habit-streaks', type: 'HabitStreaks', x: 4, y: 4, w: 4, h: 3, visible: true },
|
||||
{ id: 'calendar-mini', type: 'CalendarMini', x: 8, y: 4, w: 4, h: 3, visible: true },
|
||||
{ id: 'quick-add', type: 'QuickAdd', x: 0, y: 7, w: 3, h: 3, visible: true },
|
||||
{ id: 'recent-activity', type: 'RecentActivity', x: 3, y: 7, w: 9, h: 3, visible: true },
|
||||
];
|
||||
|
||||
export const useDashboardStore = create<DashboardState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
widgets: defaultWidgets,
|
||||
|
||||
setWidgets: (widgets) => set({ widgets }),
|
||||
updateWidget: (id, updates) =>
|
||||
set((state) => ({
|
||||
widgets: state.widgets.map((w) =>
|
||||
w.id === id ? { ...w, ...updates } : w
|
||||
),
|
||||
})),
|
||||
addWidget: (widget) =>
|
||||
set((state) => ({
|
||||
widgets: [...state.widgets, widget],
|
||||
})),
|
||||
removeWidget: (id) =>
|
||||
set((state) => ({
|
||||
widgets: state.widgets.filter((w) => w.id !== id),
|
||||
})),
|
||||
resetLayout: () => set({ widgets: defaultWidgets }),
|
||||
}),
|
||||
{
|
||||
name: 'project-e-dashboard',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface FilterState {
|
||||
domain: string | null;
|
||||
project: string | null;
|
||||
tags: string[];
|
||||
status: string | null;
|
||||
priority: string | null;
|
||||
|
||||
// Actions
|
||||
setDomain: (domain: string | null) => void;
|
||||
setProject: (project: string | null) => void;
|
||||
setTags: (tags: string[]) => void;
|
||||
addTag: (tag: string) => void;
|
||||
removeTag: (tag: string) => void;
|
||||
setStatus: (status: string | null) => void;
|
||||
setPriority: (priority: string | null) => void;
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
export const useFilterStore = create<FilterState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
domain: null,
|
||||
project: null,
|
||||
tags: [],
|
||||
status: null,
|
||||
priority: null,
|
||||
|
||||
setDomain: (domain) => set({ domain }),
|
||||
setProject: (project) => set({ project }),
|
||||
setTags: (tags) => set({ tags }),
|
||||
addTag: (tag) =>
|
||||
set((state) => ({
|
||||
tags: state.tags.includes(tag) ? state.tags : [...state.tags, tag],
|
||||
})),
|
||||
removeTag: (tag) =>
|
||||
set((state) => ({
|
||||
tags: state.tags.filter((t) => t !== tag),
|
||||
})),
|
||||
setStatus: (status) => set({ status }),
|
||||
setPriority: (priority) => set({ priority }),
|
||||
clearAll: () =>
|
||||
set({
|
||||
domain: null,
|
||||
project: null,
|
||||
tags: [],
|
||||
status: null,
|
||||
priority: null,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'project-e-filters',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface Shortcut {
|
||||
key: string;
|
||||
description: string;
|
||||
action: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
enabled: boolean;
|
||||
shortcuts: Shortcut[];
|
||||
|
||||
// Actions
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
updateShortcut: (key: string, updates: Partial<Shortcut>) => void;
|
||||
resetShortcuts: () => void;
|
||||
}
|
||||
|
||||
const defaultShortcuts: Shortcut[] = [
|
||||
{ key: 'g+d', description: 'Go to Dashboard', action: 'navigate_dashboard', enabled: true },
|
||||
{ key: 'g+t', description: 'Go to Tasks', action: 'navigate_tasks', enabled: true },
|
||||
{ key: 'g+h', description: 'Go to Habits', action: 'navigate_habits', enabled: true },
|
||||
{ key: 'g+p', description: 'Go to Projects', action: 'navigate_projects', enabled: true },
|
||||
{ key: 'g+n', description: 'Go to Notes', action: 'navigate_notes', enabled: true },
|
||||
{ key: 'g+r', description: 'Go to Reports', action: 'navigate_reports', enabled: true },
|
||||
{ key: 'g+c', description: 'Go to Calendar', action: 'navigate_calendar', enabled: true },
|
||||
{ key: 'g+a', description: 'Go to Analytics', action: 'navigate_analytics', enabled: true },
|
||||
{ key: '/', description: 'Focus search', action: 'focus_search', enabled: true },
|
||||
{ key: 'n', description: 'Quick add', action: 'quick_add', enabled: true },
|
||||
{ key: '?', description: 'Show shortcuts help', action: 'show_help', enabled: true },
|
||||
{ key: 'j', description: 'Move down', action: 'move_down', enabled: true },
|
||||
{ key: 'k', description: 'Move up', action: 'move_up', enabled: true },
|
||||
{ key: 'enter', description: 'Open selected', action: 'open', enabled: true },
|
||||
{ key: 'e', description: 'Edit selected', action: 'edit', enabled: true },
|
||||
{ key: 'space', description: 'Toggle complete', action: 'toggle_complete', enabled: true },
|
||||
{ key: 'c', description: 'Complete selected', action: 'complete', enabled: true },
|
||||
{ key: 'x', description: 'Cancel', action: 'cancel', enabled: true },
|
||||
];
|
||||
|
||||
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
enabled: true,
|
||||
shortcuts: defaultShortcuts,
|
||||
|
||||
setEnabled: (enabled) => set({ enabled }),
|
||||
updateShortcut: (key, updates) =>
|
||||
set((state) => ({
|
||||
shortcuts: state.shortcuts.map((s) =>
|
||||
s.key === key ? { ...s, ...updates } : s
|
||||
),
|
||||
})),
|
||||
resetShortcuts: () => set({ shortcuts: defaultShortcuts }),
|
||||
}),
|
||||
{
|
||||
name: 'project-e-keyboard-shortcuts',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface SidebarState {
|
||||
collapsed: boolean;
|
||||
mobileOpen: boolean;
|
||||
|
||||
// Actions
|
||||
toggle: () => void;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
setMobileOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSidebarStore = create<SidebarState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
collapsed: false,
|
||||
mobileOpen: false,
|
||||
|
||||
toggle: () => set((state) => ({ collapsed: !state.collapsed })),
|
||||
setCollapsed: (collapsed) => set({ collapsed }),
|
||||
setMobileOpen: (mobileOpen) => set({ mobileOpen }),
|
||||
}),
|
||||
{
|
||||
name: 'project-e-sidebar',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
type Density = 'compact' | 'comfortable' | 'spacious';
|
||||
|
||||
interface ThemeState {
|
||||
mode: ThemeMode;
|
||||
accent: string;
|
||||
font: string;
|
||||
density: Density;
|
||||
reducedMotion: boolean;
|
||||
|
||||
// Actions
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
setAccent: (color: string) => void;
|
||||
setFont: (font: string) => void;
|
||||
setDensity: (density: Density) => void;
|
||||
setReducedMotion: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
mode: 'system',
|
||||
accent: '#356bff',
|
||||
font: 'geist',
|
||||
density: 'comfortable',
|
||||
reducedMotion: false,
|
||||
|
||||
setMode: (mode) => set({ mode }),
|
||||
setAccent: (accent) => set({ accent }),
|
||||
setFont: (font) => set({ font }),
|
||||
setDensity: (density) => set({ density }),
|
||||
setReducedMotion: (reducedMotion) => set({ reducedMotion }),
|
||||
}),
|
||||
{
|
||||
name: 'project-e-theme',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type TimerMode = 'focus' | 'break' | 'idle';
|
||||
|
||||
interface TimerState {
|
||||
mode: TimerMode;
|
||||
isRunning: boolean;
|
||||
secondsRemaining: number;
|
||||
focusMinutes: number;
|
||||
breakMinutes: number;
|
||||
currentTaskId: string | null;
|
||||
|
||||
// Actions
|
||||
startFocus: (taskId?: string) => void;
|
||||
startBreak: () => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
reset: () => void;
|
||||
tick: () => void;
|
||||
setFocusMinutes: (minutes: number) => void;
|
||||
setBreakMinutes: (minutes: number) => void;
|
||||
}
|
||||
|
||||
export const useTimerStore = create<TimerState>()((set, get) => ({
|
||||
mode: 'idle',
|
||||
isRunning: false,
|
||||
secondsRemaining: 0,
|
||||
focusMinutes: 25,
|
||||
breakMinutes: 5,
|
||||
currentTaskId: null,
|
||||
|
||||
startFocus: (taskId) =>
|
||||
set({
|
||||
mode: 'focus',
|
||||
isRunning: true,
|
||||
secondsRemaining: get().focusMinutes * 60,
|
||||
currentTaskId: taskId ?? null,
|
||||
}),
|
||||
|
||||
startBreak: () =>
|
||||
set({
|
||||
mode: 'break',
|
||||
isRunning: true,
|
||||
secondsRemaining: get().breakMinutes * 60,
|
||||
}),
|
||||
|
||||
pause: () => set({ isRunning: false }),
|
||||
resume: () => set({ isRunning: true }),
|
||||
|
||||
reset: () =>
|
||||
set({
|
||||
mode: 'idle',
|
||||
isRunning: false,
|
||||
secondsRemaining: 0,
|
||||
currentTaskId: null,
|
||||
}),
|
||||
|
||||
tick: () => {
|
||||
const state = get();
|
||||
if (!state.isRunning || state.secondsRemaining <= 0) {
|
||||
if (state.secondsRemaining <= 0) {
|
||||
set({ isRunning: false, mode: 'idle' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
set({ secondsRemaining: state.secondsRemaining - 1 });
|
||||
},
|
||||
|
||||
setFocusMinutes: (focusMinutes) => set({ focusMinutes }),
|
||||
setBreakMinutes: (breakMinutes) => set({ breakMinutes }),
|
||||
}));
|
||||
@@ -0,0 +1,90 @@
|
||||
// Theme configuration and utilities
|
||||
|
||||
export const ACCENT_COLORS = [
|
||||
{ name: 'Blue', value: '#356bff' },
|
||||
{ name: 'Green', value: '#256e4b' },
|
||||
{ name: 'Coral', value: '#e86f51' },
|
||||
{ name: 'Violet', value: '#7c3aed' },
|
||||
{ name: 'Amber', value: '#d99a33' },
|
||||
{ name: 'Rose', value: '#e11d48' },
|
||||
{ name: 'Teal', value: '#0d9488' },
|
||||
{ name: 'Indigo', value: '#4f46e5' },
|
||||
] as const;
|
||||
|
||||
export const FONTS = [
|
||||
{ name: 'Geist', value: 'geist' },
|
||||
{ name: 'System', value: 'system' },
|
||||
{ name: 'Serif', value: 'serif' },
|
||||
{ name: 'Mono', value: 'mono' },
|
||||
] as const;
|
||||
|
||||
export const DENSITIES = [
|
||||
{ name: 'Compact', value: 'compact', description: 'Tighter spacing, more content visible' },
|
||||
{ name: 'Comfortable', value: 'comfortable', description: 'Balanced spacing' },
|
||||
{ name: 'Spacious', value: 'spacious', description: 'More breathing room' },
|
||||
] as const;
|
||||
|
||||
export const THEME_MODES = [
|
||||
{ name: 'Light', value: 'light' },
|
||||
{ name: 'Dark', value: 'dark' },
|
||||
{ name: 'System', value: 'system' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Convert hex color to HSL components for CSS variable usage.
|
||||
*/
|
||||
export function hexToHSL(hex: string): { h: number; s: number; l: number } {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return { h: 0, s: 0, l: 0 };
|
||||
|
||||
let r = parseInt(result[1], 16) / 255;
|
||||
let g = parseInt(result[2], 16) / 255;
|
||||
let b = parseInt(result[3], 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
case b:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get font family string from font value.
|
||||
*/
|
||||
export function getFontFamily(font: string): string {
|
||||
switch (font) {
|
||||
case 'geist':
|
||||
return 'var(--font-geist-sans), system-ui, sans-serif';
|
||||
case 'system':
|
||||
return 'system-ui, -apple-system, sans-serif';
|
||||
case 'serif':
|
||||
return 'Georgia, "Times New Roman", serif';
|
||||
case 'mono':
|
||||
return 'var(--font-geist-mono), monospace';
|
||||
default:
|
||||
return 'var(--font-geist-sans), system-ui, sans-serif';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user