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:
2026-07-16 06:19:58 -04:00
parent ec14645a4b
commit 8f55626e03
286 changed files with 31992 additions and 9245 deletions
+137
View File
@@ -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 }
);
}
+105
View File
@@ -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 });
}
+92
View File
@@ -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);
}
+38
View File
@@ -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;
}
+106
View File
@@ -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) }));
}
});
}
+202
View File
@@ -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) }));
}
});
}
+92
View File
@@ -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) }));
}
});
}
+184
View File
@@ -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) }));
}
});
}
+117
View File
@@ -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) }));
}
});
}
+141
View File
@@ -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) }));
}
});
}
+138
View File
@@ -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) }));
}
});
}
+113
View File
@@ -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) }));
}
});
}
+86
View File
@@ -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) }));
}
});
}
+205
View File
@@ -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) }));
}
});
}
+108
View File
@@ -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) }));
}
});
}
+113
View File
@@ -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);
}
+4
View File
@@ -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),
};
}
}
+247
View File
@@ -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);
}
+7
View File
@@ -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';
+307
View File
@@ -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 };
}
+140
View File
@@ -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,
});
}
+269
View File
@@ -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 };
}
+176
View File
@@ -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);
}
+161
View File
@@ -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,
});
}
+6
View File
@@ -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',
}
)
);
+57
View File
@@ -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',
}
)
);
+28
View File
@@ -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',
}
)
);
+41
View File
@@ -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',
}
)
);
+71
View File
@@ -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 }),
}));
+90
View File
@@ -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';
}
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}