task-manager-research
TaskPilot GUI - Practical Build Plan
Level of certainty: 85% - I've built similar tools, but your specific Taskwarrior hook integration adds some unknowns.
🎯 Recommended Stack (For Your Situation)
Go with Option A: Web + Local API
Why:
- You're already comfortable with React (ChainAlign uses it)
- Can reuse your brand system components
- Easier to iterate quickly with AI assistants
- Can deploy as electron app later if needed
- localhost:3000 is fine for personal use
Skip Tauri because:
- Adds Rust complexity (unless you already know it)
- Desktop packaging isn't needed yet
- Web-first gets you to working GUI faster
🏗️ Concrete Implementation Plan
Phase 1: Backend API (2-3 hours)
File: taskpilot-api/server.py
#!/usr/bin/env python3
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
import subprocess
import json
from typing import Optional
from pydantic import BaseModel
app = FastAPI()
# Allow React dev server to connect
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
class Task(BaseModel):
description: str
project: Optional[str] = None
milestone: Optional[str] = None
depends: Optional[str] = None
priority: Optional[str] = None
due: Optional[str] = None
def run_task_command(args: list) -> dict:
"""Wrapper for taskwarrior CLI calls"""
try:
result = subprocess.check_output(
["task"] + args,
stderr=subprocess.PIPE
)
return {"success": True, "output": result.decode()}
except subprocess.CalledProcessError as e:
return {"success": False, "error": e.stderr.decode()}
@app.get("/tasks")
def get_tasks(project: Optional[str] = None, milestone: Optional[str] = None):
"""Get all tasks, optionally filtered"""
filter_args = []
if project:
filter_args.append(f"project:{project}")
if milestone:
filter_args.append(f"milestone:{milestone}")
try:
result = subprocess.check_output(
["task"] + filter_args + ["export"],
stderr=subprocess.PIPE
)
tasks = json.loads(result.decode())
return {"tasks": tasks}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/projects")
def get_projects():
"""Get all unique projects with milestone counts"""
try:
result = subprocess.check_output(
["task", "export"],
stderr=subprocess.PIPE
)
tasks = json.loads(result.decode())
projects = {}
for task in tasks:
proj = task.get("project", "inbox")
milestone = task.get("milestone", "none")
if proj not in projects:
projects[proj] = {"milestones": set(), "count": 0}
projects[proj]["milestones"].add(milestone)
projects[proj]["count"] += 1
# Convert sets to lists for JSON serialization
for proj in projects:
projects[proj]["milestones"] = sorted(list(projects[proj]["milestones"]))
return {"projects": projects}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/tasks")
def add_task(task: Task):
"""Create new task via Taskwarrior CLI"""
cmd = ["task", "add"]
if task.project:
cmd.append(f"project:{task.project}")
if task.milestone:
cmd.append(f"milestone:{task.milestone}")
if task.depends:
cmd.append(f"depends:{task.depends}")
if task.priority:
cmd.append(f"priority:{task.priority}")
if task.due:
cmd.append(f"due:{task.due}")
cmd.append(task.description)
result = run_task_command(cmd)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return {"message": "Task created", "output": result["output"]}
@app.patch("/tasks/{task_id}/complete")
def complete_task(task_id: str):
"""Mark task as done"""
result = run_task_command([task_id, "done"])
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return {"message": f"Task {task_id} completed"}
@app.patch("/tasks/{task_id}")
def update_task(task_id: str, updates: dict):
"""Modify task fields"""
cmd = [task_id, "modify"]
for key, value in updates.items():
cmd.append(f"{key}:{value}")
result = run_task_command(cmd)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return {"message": f"Task {task_id} updated"}
@app.get("/dependency-tree")
def get_dependency_tree(project: Optional[str] = None):
"""Get task dependency tree data"""
filter_args = [f"project:{project}"] if project else []
try:
result = subprocess.check_output(
["task"] + filter_args + ["export"],
stderr=subprocess.PIPE
)
tasks = json.loads(result.decode())
# Build tree structure
tree = []
task_map = {t["uuid"]: t for t in tasks}
for task in tasks:
depends = task.get("depends", [])
if isinstance(depends, str):
depends = depends.split(",")
tree.append({
"id": task["uuid"],
"pseudo_id": task.get("pseudo_id", ""),
"description": task["description"],
"status": task["status"],
"dependencies": depends
})
return {"tree": tree}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)
Install & Run:
pip install fastapi uvicorn --break-system-packages
python taskpilot-api/server.py
Phase 2: React Frontend (4-6 hours)
Setup:
npx create-react-app taskpilot-ui
cd taskpilot-ui
npm install @tanstack/react-query axios lucide-react
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
File: src/theme.js (ChainAlign Brand Tokens)
export const colors = {
navy: '#0B1D3A',
green: '#00D084',
greenHover: '#00B870',
white: '#FFFFFF',
grayBg: '#F2F4F7',
grayMedium: '#E5E7EB',
charcoal: '#1E1E1E',
amber: '#F59E0B',
red: '#EF4444',
softBlue: '#E8F2FF',
}
export const spacing = {
xs: '8px',
sm: '16px',
md: '24px',
lg: '32px',
xl: '48px',
}
File: src/App.jsx (Simplified MVP)
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { Plus, CheckCircle, Circle } from 'lucide-react';
import { colors, spacing } from './theme';
const API_BASE = 'http://localhost:8000';
function App() {
const [selectedProject, setSelectedProject] = useState('chainalign');
const [selectedMilestone, setSelectedMilestone] = useState(null);
const queryClient = useQueryClient();
// Fetch projects
const { data: projectsData } = useQuery({
queryKey: ['projects'],
queryFn: async () => {
const res = await axios.get(`${API_BASE}/projects`);
return res.data.projects;
},
});
// Fetch tasks
const { data: tasksData } = useQuery({
queryKey: ['tasks', selectedProject, selectedMilestone],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedProject) params.append('project', selectedProject);
if (selectedMilestone) params.append('milestone', selectedMilestone);
const res = await axios.get(`${API_BASE}/tasks?${params}`);
return res.data.tasks;
},
});
// Complete task mutation
const completeMutation = useMutation({
mutationFn: (taskId) => axios.patch(`${API_BASE}/tasks/${taskId}/complete`),
onSuccess: () => queryClient.invalidateQueries(['tasks']),
});
return (
<div style={{
fontFamily: 'Inter, sans-serif',
backgroundColor: colors.white,
minHeight: '100vh'
}}>
{/* Header */}
<header style={{
backgroundColor: colors.navy,
color: colors.white,
padding: spacing.md,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>TaskPilot</h1>
<button style={{
backgroundColor: colors.green,
color: colors.white,
padding: '8px 16px',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<Plus size={20} /> Add Task
</button>
</header>
<div style={{ display: 'flex', height: 'calc(100vh - 80px)' }}>
{/* Sidebar */}
<aside style={{
width: '280px',
backgroundColor: colors.grayBg,
padding: spacing.md,
borderRight: `1px solid ${colors.grayMedium}`
}}>
<h2 style={{ fontSize: '14px', fontWeight: 600, marginBottom: spacing.sm }}>
PROJECTS
</h2>
{projectsData && Object.entries(projectsData).map(([project, info]) => (
<div key={project}>
<div
onClick={() => setSelectedProject(project)}
style={{
padding: '8px 12px',
cursor: 'pointer',
backgroundColor: selectedProject === project ? colors.softBlue : 'transparent',
borderRadius: '4px',
marginBottom: '4px',
fontWeight: selectedProject === project ? 600 : 400
}}
>
{project} ({info.count})
</div>
{selectedProject === project && (
<div style={{ marginLeft: spacing.sm }}>
{info.milestones.map(milestone => (
<div
key={milestone}
onClick={() => setSelectedMilestone(milestone)}
style={{
padding: '6px 12px',
cursor: 'pointer',
fontSize: '14px',
color: selectedMilestone === milestone ? colors.green : colors.charcoal,
fontWeight: selectedMilestone === milestone ? 600 : 400
}}
>
{milestone}
</div>
))}
</div>
)}
</div>
))}
</aside>
{/* Main Content */}
<main style={{ flex: 1, padding: spacing.lg }}>
<h2 style={{
fontSize: '20px',
fontWeight: 700,
marginBottom: spacing.md,
color: colors.navy
}}>
{selectedMilestone ? `Milestone: ${selectedMilestone}` : 'All Tasks'}
</h2>
{tasksData?.map(task => (
<div key={task.uuid} style={{
backgroundColor: colors.white,
border: `1px solid ${colors.grayMedium}`,
borderRadius: '8px',
padding: spacing.sm,
marginBottom: spacing.sm,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: spacing.sm }}>
<button
onClick={() => completeMutation.mutate(task.uuid)}
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
>
{task.status === 'completed' ? (
<CheckCircle size={24} color={colors.green} />
) : (
<Circle size={24} color={colors.grayMedium} />
)}
</button>
<div>
<div style={{ fontWeight: 600, color: colors.navy }}>
{task.pseudo_id || task.id} {task.description}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{task.project} • {task.milestone}
</div>
</div>
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{task.due && `Due: ${task.due}`}
</div>
</div>
))}
</main>
</div>
</div>
);
}
export default App;
File: src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
const queryClient = new QueryClient();
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
🚀 Launch It
# Terminal 1: Start backend
cd taskpilot-api
python server.py
# Terminal 2: Start frontend
cd taskpilot-ui
npm start
Visit: http://localhost:3000
⚠️ Known Limitations (MVP)
| Feature | Status | Effort to Add |
|---|---|---|
| Add task form | ❌ Not implemented | 1 hour |
| Edit task inline | ❌ Not implemented | 2 hours |
| Dependency tree visualization | ❌ Not implemented | 4 hours (D3.js) |
| Real-time file watching | ❌ Needs polling or websocket | 2 hours |
| Search/filter | ❌ Basic filtering only | 1 hour |
| Drag-and-drop reordering | ❌ Not implemented | 3 hours |
What works:
- ✅ View all projects/milestones
- ✅ Filter tasks by project/milestone
- ✅ Complete tasks with one click
- ✅ See pseudo-IDs
- ✅ ChainAlign brand styling
🎯 Next Steps (Priority Order)
- Add Task Form (30 min) - Modal with fields for description, project, milestone, depends
- Auto-refresh (1 hour) - Poll
/tasksendpoint every 5s or use websocket - Search Bar (1 hour) - Client-side fuzzy search with Fuse.js
- Task Details Panel (2 hours) - Right sidebar showing full task metadata
- Dependency Tree (4 hours) - D3.js force-directed graph
💡 Things I'm Uncertain About
- Performance with 1000+ tasks: Haven't tested API response time at scale
- Taskwarrior lock conflicts: If GUI and CLI are used simultaneously, there might be race conditions
- Hook execution from API: The Python subprocess calls should trigger your hooks, but worth testing
What would change my recommendation:
- If you want offline mobile access → build Tauri desktop app
- If you need team collaboration → add proper auth + cloud sync
- If speed is critical → switch to Rust backend
GraphQL for Task Management - Strategic Analysis
Level of certainty: 90% - GraphQL is the right call for a product feature. The question is: what's the storage strategy?
🤔 Critical Decision First: Storage Strategy
You have two paths here, and GraphQL works for both:
Path A: Taskwarrior Backend (Personal Tool)
- GraphQL wraps Taskwarrior CLI
- Data stays in
~/.task/files - Single-user (you)
- Can't sync across devices
- Can't be used by ChainAlign team
Path B: PostgreSQL Backend (Product Feature)
- GraphQL queries ChainAlign's PostgreSQL database
- New
taskstable in your existing schema - Multi-tenant support (tenant_id)
- Team collaboration ready
- Integrates with audit_log, users, etc.
- Can link tasks to
core_entities,sop_cycles, etc.
📊 Comparison Matrix
| Feature | Taskwarrior + GraphQL | PostgreSQL + GraphQL |
|---|---|---|
| Dev time | 2-3 days | 5-7 days |
| Multi-user | ❌ No | ✅ Yes |
| Real-time sync | ❌ No | ✅ Yes (subscriptions) |
| Audit trail | Limited | ✅ Built-in |
| ChainAlign integration | ❌ Isolated | ✅ Native |
| Your personal use | ✅ Works now | ✅ Works + more |
| Team rollout | ❌ Can't scale | ✅ Ready |
| Data ownership | Local files | Cloud (Supabase) |
🎯 My Recommendation: Path B (PostgreSQL)
Why:
- You said "foundation for ChainAlign task manager" - that means product feature, not personal tool
- Your architecture already has multi-tenant PostgreSQL
- GraphQL + PostgreSQL is a proven pattern (Hasura, PostGraphile)
- You can link tasks to products, S&OP cycles, insights
- Team members can collaborate on tasks
- Fits the "real-time intelligence" vision
Trade-off:
- Takes longer to build initially
- But you're building a real product feature, not duct-taping CLI tools
You can still use Taskwarrior locally:
- Keep your personal setup for quick CLI access
- Use TaskPilot for ChainAlign project tasks
- They can coexist
🏗️ Proposed Architecture (PostgreSQL + GraphQL)
Database Schema Extension
New table: tasks
CREATE TABLE tasks (
task_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(tenant_id) ON DELETE CASCADE,
created_by_user_id UUID NOT NULL REFERENCES users(user_id),
assigned_to_user_id UUID REFERENCES users(user_id),
-- Task identification
pseudo_id VARCHAR(50) UNIQUE, -- e.g., "M1-001"
title TEXT NOT NULL,
description TEXT,
-- Organization
project VARCHAR(255),
milestone VARCHAR(100),
status VARCHAR(50) NOT NULL DEFAULT 'pending', -- pending, in_progress, completed, blocked
priority VARCHAR(20), -- low, medium, high, critical
-- Relationships
parent_task_id UUID REFERENCES tasks(task_id) ON DELETE SET NULL,
depends_on UUID[], -- Array of task_ids this depends on
-- ChainAlign integration
related_entity_id UUID REFERENCES core_entities(entity_id), -- Link to product, etc.
related_cycle_id UUID REFERENCES sop_cycles(cycle_id),
related_insight_id UUID REFERENCES insights(insight_id),
-- Dates
due_date TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
-- Metadata
tags TEXT[],
estimated_hours NUMERIC(5,2),
actual_hours NUMERIC(5,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX idx_tasks_tenant_id ON tasks(tenant_id);
CREATE INDEX idx_tasks_project_milestone ON tasks(project, milestone);
CREATE INDEX idx_tasks_assigned_to ON tasks(assigned_to_user_id);
CREATE INDEX idx_tasks_parent ON tasks(parent_task_id);
CREATE INDEX idx_tasks_status ON tasks(status);
-- Trigger for auto-updating updated_at
CREATE TRIGGER update_tasks_updated_at
BEFORE UPDATE ON tasks
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Add to audit_log:
-- Already exists, just use it for task changes
-- action_type examples: 'task_created', 'task_completed', 'task_reassigned'
GraphQL Schema
File: schema.graphql
type Task {
taskId: ID!
pseudoId: String
title: String!
description: String
project: String
milestone: String
status: TaskStatus!
priority: TaskPriority
# Relationships
parentTask: Task
subtasks: [Task!]!
dependencies: [Task!]!
blockedBy: [Task!]!
# Users
createdBy: User!
assignedTo: User
# ChainAlign integration
relatedEntity: CoreEntity
relatedCycle: SOPCycle
relatedInsight: Insight
# Dates
dueDate: DateTime
startedAt: DateTime
completedAt: DateTime
# Metadata
tags: [String!]!
estimatedHours: Float
actualHours: Float
createdAt: DateTime!
updatedAt: DateTime!
}
enum TaskStatus {
PENDING
IN_PROGRESS
COMPLETED
BLOCKED
CANCELLED
}
enum TaskPriority {
LOW
MEDIUM
HIGH
CRITICAL
}
type Query {
# Get tasks with flexible filtering
tasks(
projectId: String
milestone: String
status: TaskStatus
assignedToUserId: ID
relatedEntityId: ID
): [Task!]!
# Get single task
task(taskId: ID!): Task
# Get task tree (with all subtasks)
taskTree(rootTaskId: ID!): Task
# Get tasks by pseudo-ID
taskByPseudoId(pseudoId: String!): Task
# Search tasks
searchTasks(query: String!): [Task!]!
# Get projects with task counts
projects: [Project!]!
}
type Project {
name: String!
milestones: [Milestone!]!
taskCount: Int!
}
type Milestone {
name: String!
taskCount: Int!
completedCount: Int!
}
type Mutation {
# Create task
createTask(input: CreateTaskInput!): Task!
# Update task
updateTask(taskId: ID!, input: UpdateTaskInput!): Task!
# Complete task
completeTask(taskId: ID!): Task!
# Assign task
assignTask(taskId: ID!, userId: ID!): Task!
# Add dependency
addDependency(taskId: ID!, dependsOnTaskId: ID!): Task!
# Delete task
deleteTask(taskId: ID!): Boolean!
}
input CreateTaskInput {
title: String!
description: String
project: String
milestone: String
priority: TaskPriority
parentTaskId: ID
assignedToUserId: ID
dueDate: DateTime
tags: [String!]
relatedEntityId: ID
relatedCycleId: ID
}
input UpdateTaskInput {
title: String
description: String
status: TaskStatus
priority: TaskPriority
dueDate: DateTime
estimatedHours: Float
actualHours: Float
tags: [String!]
}
type Subscription {
# Real-time task updates
taskUpdated(projectId: String): Task!
# New tasks in project
taskCreated(projectId: String): Task!
# Task completed
taskCompleted(projectId: String): Task!
}
GraphQL Server Implementation
Tech Stack:
- Apollo Server (Node.js) - matches your existing API Gateway
- Prisma - type-safe ORM for PostgreSQL
- graphql-subscriptions - for real-time updates
File: taskpilot-graphql/server.js
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');
const { PubSub } = require('graphql-subscriptions');
const express = require('express');
const { createServer } = require('http');
const { PrismaClient } = require('@prisma/client');
const admin = require('firebase-admin');
// Initialize
const prisma = new PrismaClient();
const pubsub = new PubSub();
const app = express();
const httpServer = createServer(app);
// Firebase Auth (reuse ChainAlign's setup)
admin.initializeApp({
credential: admin.credential.applicationDefault(),
});
// GraphQL Schema
const typeDefs = `
# [paste schema.graphql here]
`;
// Resolvers
const resolvers = {
Query: {
tasks: async (_, { projectId, milestone, status, assignedToUserId }, context) => {
const where = { tenantId: context.user.tenantId };
if (projectId) where.project = projectId;
if (milestone) where.milestone = milestone;
if (status) where.status = status;
if (assignedToUserId) where.assignedToUserId = assignedToUserId;
return prisma.task.findMany({
where,
include: {
createdBy: true,
assignedTo: true,
parentTask: true,
subtasks: true,
},
orderBy: { createdAt: 'desc' },
});
},
task: async (_, { taskId }, context) => {
return prisma.task.findUnique({
where: { taskId },
include: {
createdBy: true,
assignedTo: true,
parentTask: true,
subtasks: true,
relatedEntity: true,
relatedCycle: true,
},
});
},
taskTree: async (_, { rootTaskId }, context) => {
// Recursive query to get all subtasks
const getSubtasks = async (taskId) => {
const task = await prisma.task.findUnique({
where: { taskId },
include: { subtasks: true },
});
if (task.subtasks.length > 0) {
task.subtasks = await Promise.all(
task.subtasks.map(sub => getSubtasks(sub.taskId))
);
}
return task;
};
return getSubtasks(rootTaskId);
},
projects: async (_, __, context) => {
const tasks = await prisma.task.findMany({
where: { tenantId: context.user.tenantId },
select: { project: true, milestone: true },
});
// Group by project
const projectMap = {};
tasks.forEach(task => {
const proj = task.project || 'inbox';
if (!projectMap[proj]) {
projectMap[proj] = { name: proj, milestones: new Set(), taskCount: 0 };
}
projectMap[proj].milestones.add(task.milestone);
projectMap[proj].taskCount++;
});
return Object.values(projectMap).map(p => ({
...p,
milestones: Array.from(p.milestones).map(m => ({ name: m, taskCount: 0 })),
}));
},
},
Mutation: {
createTask: async (_, { input }, context) => {
const { user } = context;
// Generate pseudo-ID
let pseudoId = null;
if (input.milestone) {
const count = await prisma.task.count({
where: {
tenantId: user.tenantId,
milestone: input.milestone
},
});
pseudoId = `${input.milestone}-${String(count + 1).padStart(3, '0')}`;
}
const task = await prisma.task.create({
data: {
...input,
pseudoId,
tenantId: user.tenantId,
createdByUserId: user.userId,
},
include: {
createdBy: true,
assignedTo: true,
},
});
// Audit log
await prisma.auditLog.create({
data: {
tenantId: user.tenantId,
userId: user.userId,
actionType: 'task_created',
details: { taskId: task.taskId, title: task.title },
},
});
// Publish to subscribers
pubsub.publish('TASK_CREATED', { taskCreated: task });
return task;
},
updateTask: async (_, { taskId, input }, context) => {
const task = await prisma.task.update({
where: { taskId },
data: input,
include: {
createdBy: true,
assignedTo: true,
},
});
pubsub.publish('TASK_UPDATED', { taskUpdated: task });
return task;
},
completeTask: async (_, { taskId }, context) => {
const task = await prisma.task.update({
where: { taskId },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
include: {
createdBy: true,
assignedTo: true,
},
});
await prisma.auditLog.create({
data: {
tenantId: context.user.tenantId,
userId: context.user.userId,
actionType: 'task_completed',
details: { taskId: task.taskId },
},
});
pubsub.publish('TASK_COMPLETED', { taskCompleted: task });
return task;
},
},
Subscription: {
taskUpdated: {
subscribe: () => pubsub.asyncIterator(['TASK_UPDATED']),
},
taskCreated: {
subscribe: () => pubsub.asyncIterator(['TASK_CREATED']),
},
taskCompleted: {
subscribe: () => pubsub.asyncIterator(['TASK_COMPLETED']),
},
},
Task: {
subtasks: (parent) => {
return prisma.task.findMany({
where: { parentTaskId: parent.taskId },
});
},
dependencies: async (parent) => {
if (!parent.dependsOn || parent.dependsOn.length === 0) return [];
return prisma.task.findMany({
where: { taskId: { in: parent.dependsOn } },
});
},
},
};
// Create executable schema
const schema = makeExecutableSchema({ typeDefs, resolvers });
// WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
useServer({ schema }, wsServer);
// Apollo Server
const server = new ApolloServer({ schema });
async function startServer() {
await server.start();
app.use(
'/graphql',
express.json(),
expressMiddleware(server, {
context: async ({ req }) => {
// Verify Firebase token
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) throw new Error('No auth token');
const decodedToken = await admin.auth().verifyIdToken(token);
const user = await prisma.user.findUnique({
where: { email: decodedToken.email },
});
return { user };
},
})
);
httpServer.listen(4000, () => {
console.log('🚀 GraphQL server ready at http://localhost:4000/graphql');
console.log('🔌 WebSocket ready at ws://localhost:4000/graphql');
});
}
startServer();
🎨 React Client (GraphQL)
Install:
npm install @apollo/client graphql
File: src/apollo-client.js
import { ApolloClient, InMemoryCache, createHttpLink, split } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
import { getAuth } from 'firebase/auth';
// HTTP link for queries and mutations
const httpLink = createHttpLink({
uri: 'http://localhost:4000/graphql',
});
// WebSocket link for subscriptions
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: async () => {
const auth = getAuth();
const token = await auth.currentUser?.getIdToken();
return { authorization: token ? `Bearer ${token}` : '' };
},
})
);
// Auth middleware
const authLink = setContext(async (_, { headers }) => {
const auth = getAuth();
const token = await auth.currentUser?.getIdToken();
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
// Split between HTTP and WebSocket
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
authLink.concat(httpLink)
);
export const apolloClient = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
File: src/App.jsx (GraphQL version)
import { useQuery, useMutation, useSubscription, gql } from '@apollo/client';
const GET_TASKS = gql`
query GetTasks($projectId: String, $milestone: String) {
tasks(projectId: $projectId, milestone: $milestone) {
taskId
pseudoId
title
description
status
priority
dueDate
project
milestone
assignedTo {
userId
firstName
lastName
}
}
}
`;
const COMPLETE_TASK = gql`
mutation CompleteTask($taskId: ID!) {
completeTask(taskId: $taskId) {
taskId
status
completedAt
}
}
`;
const TASK_CREATED_SUBSCRIPTION = gql`
subscription OnTaskCreated($projectId: String) {
taskCreated(projectId: $projectId) {
taskId
title
pseudoId
}
}
`;
function App() {
const [selectedProject, setSelectedProject] = useState('chainalign');
const { data, loading, refetch } = useQuery(GET_TASKS, {
variables: { projectId: selectedProject },
});
const [completeTask] = useMutation(COMPLETE_TASK, {
refetchQueries: [{ query: GET_TASKS }],
});
// Real-time updates
useSubscription(TASK_CREATED_SUBSCRIPTION, {
variables: { projectId: selectedProject },
onData: () => refetch(),
});
// ... rest of UI similar to before, but using GraphQL data
}
📈 Implementation Timeline
| Phase | Deliverable | Time | Priority |
|---|---|---|---|
| 1. Schema Design | PostgreSQL migration + GraphQL schema | 4 hours | P0 |
| 2. Server Setup | Apollo Server + auth + resolvers | 8 hours | P0 |
| 3. Basic UI | Task list, create, complete | 6 hours | P0 |
| 4. Real-time | Subscriptions for live updates | 4 hours | P1 |
| 5. Advanced Features | Drag-drop, tree view, search | 12 hours | P2 |
| 6. Integration | Link to entities, cycles, insights | 8 hours | P2 |
Total MVP: ~2 days of focused work
✅ Benefits You Get
- Now: Personal task management with proper architecture
- Soon: Team members can collaborate on ChainAlign tasks
- Later: Link tasks to products, S&OP cycles, insights
- Eventually: AI-generated task recommendations based on S&OP data
🤔 Open Questions
- Do you want to migrate your existing Taskwarrior tasks to PostgreSQL?
- I can write a migration script
- Should tasks be tenant-scoped or project-scoped?
- Tenant = all users in your company see same tasks
- Project = tasks are private unless shared
- Do you want Kanban board view or just list view initially?
What would change my opinion:
- If you need offline-first → stick with Taskwarrior
- If this is just for you → REST API is simpler
- If you're not sure about team usage → maybe build both (Taskwarrior for personal, PostgreSQL for team)
TaskPilot: Production Implementation Plan
Decision: PostgreSQL + GraphQL ✓
Building for all 3 use cases simultaneously.
1. Database Schema
Migration file: migrations/20250102_add_tasks.sql
-- Tasks table
CREATE TABLE tasks (
task_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(tenant_id) ON DELETE CASCADE,
created_by_user_id UUID NOT NULL REFERENCES users(user_id),
assigned_to_user_id UUID REFERENCES users(user_id),
-- Identification
pseudo_id VARCHAR(50) UNIQUE,
title TEXT NOT NULL,
description TEXT,
-- Organization
project VARCHAR(255) NOT NULL,
milestone VARCHAR(100),
status VARCHAR(50) NOT NULL DEFAULT 'pending',
priority VARCHAR(20),
-- Hierarchy
parent_task_id UUID REFERENCES tasks(task_id) ON DELETE CASCADE,
depends_on UUID[],
-- ChainAlign integration
related_entity_id UUID REFERENCES core_entities(entity_id) ON DELETE SET NULL,
related_cycle_id UUID REFERENCES sop_cycles(cycle_id) ON DELETE SET NULL,
related_insight_id UUID REFERENCES insights(insight_id) ON DELETE SET NULL,
-- Dates
due_date TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
-- Metadata
tags TEXT[] DEFAULT '{}',
estimated_hours NUMERIC(5,2),
actual_hours NUMERIC(5,2),
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Constraints
CONSTRAINT valid_status CHECK (status IN ('pending', 'in_progress', 'completed', 'blocked', 'cancelled')),
CONSTRAINT valid_priority CHECK (priority IN ('low', 'medium', 'high', 'critical'))
);
-- Indexes
CREATE INDEX idx_tasks_tenant_id ON tasks(tenant_id);
CREATE INDEX idx_tasks_project_milestone ON tasks(project, milestone);
CREATE INDEX idx_tasks_assigned_to ON tasks(assigned_to_user_id);
CREATE INDEX idx_tasks_parent ON tasks(parent_task_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_pseudo_id ON tasks(pseudo_id) WHERE pseudo_id IS NOT NULL;
CREATE INDEX idx_tasks_depends_on ON tasks USING gin(depends_on);
-- Trigger for updated_at
CREATE TRIGGER update_tasks_updated_at
BEFORE UPDATE ON tasks
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Task comments
CREATE TABLE task_comments (
comment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(task_id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(user_id),
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_task_comments_task_id ON task_comments(task_id);
-- Task activity log (auto-populated via trigger)
CREATE TABLE task_activity (
activity_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(task_id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(user_id),
action VARCHAR(50) NOT NULL,
changes JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_task_activity_task_id ON task_activity(task_id);
CREATE INDEX idx_task_activity_created_at ON task_activity(created_at);
-- Trigger to log all task changes
CREATE OR REPLACE FUNCTION log_task_activity()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO task_activity (task_id, user_id, action, changes)
VALUES (NEW.task_id, NEW.created_by_user_id, 'created',
jsonb_build_object('title', NEW.title, 'status', NEW.status));
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO task_activity (task_id, user_id, action, changes)
VALUES (NEW.task_id, NEW.updated_by_user_id, 'updated',
jsonb_build_object('old', row_to_json(OLD), 'new', row_to_json(NEW)));
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER task_activity_trigger
AFTER INSERT OR UPDATE ON tasks
FOR EACH ROW
EXECUTE FUNCTION log_task_activity();
-- Pseudo-ID generator function
CREATE OR REPLACE FUNCTION generate_pseudo_id(p_milestone VARCHAR, p_tenant_id UUID)
RETURNS VARCHAR AS $$
DECLARE
next_num INTEGER;
candidate VARCHAR;
exists BOOLEAN;
BEGIN
LOOP
SELECT COUNT(*) + 1 INTO next_num
FROM tasks
WHERE milestone = p_milestone AND tenant_id = p_tenant_id;
candidate := p_milestone || '-' || LPAD(next_num::TEXT, 3, '0');
SELECT EXISTS(SELECT 1 FROM tasks WHERE pseudo_id = candidate) INTO exists;
EXIT WHEN NOT exists;
END LOOP;
RETURN candidate;
END;
$$ LANGUAGE plpgsql;
2. GraphQL Schema
File: taskpilot-graphql/schema.graphql
scalar DateTime
scalar JSON
type Task {
taskId: ID!
pseudoId: String
title: String!
description: String
project: String!
milestone: String
status: TaskStatus!
priority: TaskPriority
# Relationships
parentTask: Task
subtasks: [Task!]!
dependencies: [Task!]!
dependents: [Task!]!
# Users
createdBy: User!
assignedTo: User
# ChainAlign integration
relatedEntity: CoreEntity
relatedCycle: SOPCycle
relatedInsight: Insight
# Dates
dueDate: DateTime
startedAt: DateTime
completedAt: DateTime
# Metadata
tags: [String!]!
estimatedHours: Float
actualHours: Float
# Activity
comments: [TaskComment!]!
activity: [TaskActivity!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type TaskComment {
commentId: ID!
task: Task!
user: User!
content: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type TaskActivity {
activityId: ID!
task: Task!
user: User!
action: String!
changes: JSON
createdAt: DateTime!
}
enum TaskStatus {
PENDING
IN_PROGRESS
COMPLETED
BLOCKED
CANCELLED
}
enum TaskPriority {
LOW
MEDIUM
HIGH
CRITICAL
}
type Project {
name: String!
milestones: [Milestone!]!
taskCount: Int!
completedCount: Int!
}
type Milestone {
name: String!
project: String!
taskCount: Int!
completedCount: Int!
progress: Float!
tasks: [Task!]!
}
type Query {
# Tasks
tasks(
project: String
milestone: String
status: TaskStatus
assignedToUserId: ID
createdByUserId: ID
search: String
): [Task!]!
task(taskId: ID!): Task
taskByPseudoId(pseudoId: String!): Task
taskTree(rootTaskId: ID!): Task
# Projects & Milestones
projects: [Project!]!
milestones(project: String!): [Milestone!]!
# My tasks
myTasks(status: TaskStatus): [Task!]!
myTasksDueThisWeek: [Task!]!
}
type Mutation {
# Create
createTask(input: CreateTaskInput!): Task!
# Update
updateTask(taskId: ID!, input: UpdateTaskInput!): Task!
startTask(taskId: ID!): Task!
completeTask(taskId: ID!, actualHours: Float): Task!
blockTask(taskId: ID!, reason: String): Task!
# Assign
assignTask(taskId: ID!, userId: ID!): Task!
# Dependencies
addDependency(taskId: ID!, dependsOnTaskId: ID!): Task!
removeDependency(taskId: ID!, dependsOnTaskId: ID!): Task!
# Comments
addComment(taskId: ID!, content: String!): TaskComment!
# Bulk operations
bulkCreateTasks(inputs: [CreateTaskInput!]!): [Task!]!
bulkUpdateStatus(taskIds: [ID!]!, status: TaskStatus!): [Task!]!
# Delete
deleteTask(taskId: ID!): Boolean!
}
input CreateTaskInput {
title: String!
description: String
project: String!
milestone: String
priority: TaskPriority
parentTaskId: ID
dependsOn: [ID!]
assignedToUserId: ID
dueDate: DateTime
estimatedHours: Float
tags: [String!]
relatedEntityId: ID
relatedCycleId: ID
relatedInsightId: ID
}
input UpdateTaskInput {
title: String
description: String
status: TaskStatus
priority: TaskPriority
milestone: String
dueDate: DateTime
estimatedHours: Float
actualHours: Float
tags: [String!]
}
type Subscription {
taskUpdated(project: String): Task!
taskCreated(project: String): Task!
taskCompleted(project: String): Task!
taskCommentAdded(taskId: ID!): TaskComment!
}
3. Server Implementation
File: taskpilot-graphql/package.json
{
"name": "taskpilot-graphql",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
},
"dependencies": {
"@apollo/server": "^4.9.5",
"@graphql-tools/schema": "^10.0.2",
"express": "^4.18.2",
"graphql": "^16.8.1",
"graphql-subscriptions": "^2.0.0",
"graphql-ws": "^5.14.3",
"pg": "^8.11.3",
"ws": "^8.16.0",
"firebase-admin": "^12.0.0"
}
}
File: taskpilot-graphql/db.js**
import pg from 'pg';
const { Pool } = pg;
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
});
export const query = (text, params) => pool.query(text, params);
File: taskpilot-graphql/resolvers.js**
import { query } from './db.js';
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
export const resolvers = {
Query: {
tasks: async (_, { project, milestone, status, assignedToUserId, search }, context) => {
let sql = `
SELECT t.*,
u1.first_name || ' ' || u1.last_name as created_by_name,
u2.first_name || ' ' || u2.last_name as assigned_to_name
FROM tasks t
LEFT JOIN users u1 ON t.created_by_user_id = u1.user_id
LEFT JOIN users u2 ON t.assigned_to_user_id = u2.user_id
WHERE t.tenant_id = $1
`;
const params = [context.user.tenant_id];
let paramIndex = 2;
if (project) {
sql += ` AND t.project = $${paramIndex++}`;
params.push(project);
}
if (milestone) {
sql += ` AND t.milestone = $${paramIndex++}`;
params.push(milestone);
}
if (status) {
sql += ` AND t.status = $${paramIndex++}`;
params.push(status.toLowerCase());
}
if (assignedToUserId) {
sql += ` AND t.assigned_to_user_id = $${paramIndex++}`;
params.push(assignedToUserId);
}
if (search) {
sql += ` AND (t.title ILIKE $${paramIndex} OR t.description ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
sql += ` ORDER BY t.created_at DESC`;
const result = await query(sql, params);
return result.rows;
},
task: async (_, { taskId }) => {
const result = await query(
'SELECT * FROM tasks WHERE task_id = $1',
[taskId]
);
return result.rows[0];
},
taskByPseudoId: async (_, { pseudoId }) => {
const result = await query(
'SELECT * FROM tasks WHERE pseudo_id = $1',
[pseudoId]
);
return result.rows[0];
},
projects: async (_, __, context) => {
const result = await query(`
SELECT
project,
milestone,
COUNT(*) as task_count,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_count
FROM tasks
WHERE tenant_id = $1
GROUP BY project, milestone
ORDER BY project, milestone
`, [context.user.tenant_id]);
const projectMap = {};
result.rows.forEach(row => {
if (!projectMap[row.project]) {
projectMap[row.project] = {
name: row.project,
milestones: [],
taskCount: 0,
completedCount: 0
};
}
projectMap[row.project].milestones.push({
name: row.milestone,
project: row.project,
taskCount: parseInt(row.task_count),
completedCount: parseInt(row.completed_count),
progress: row.task_count > 0 ? (row.completed_count / row.task_count) * 100 : 0
});
projectMap[row.project].taskCount += parseInt(row.task_count);
projectMap[row.project].completedCount += parseInt(row.completed_count);
});
return Object.values(projectMap);
},
myTasks: async (_, { status }, context) => {
let sql = `
SELECT * FROM tasks
WHERE tenant_id = $1 AND assigned_to_user_id = $2
`;
const params = [context.user.tenant_id, context.user.user_id];
if (status) {
sql += ` AND status = $3`;
params.push(status.toLowerCase());
}
sql += ` ORDER BY due_date ASC NULLS LAST, priority DESC`;
const result = await query(sql, params);
return result.rows;
},
},
Mutation: {
createTask: async (_, { input }, context) => {
const { user } = context;
// Generate pseudo-ID if milestone provided
let pseudoId = null;
if (input.milestone) {
const result = await query(
`SELECT generate_pseudo_id($1, $2) as pseudo_id`,
[input.milestone, user.tenant_id]
);
pseudoId = result.rows[0].pseudo_id;
}
const result = await query(`
INSERT INTO tasks (
tenant_id, created_by_user_id, assigned_to_user_id,
pseudo_id, title, description, project, milestone,
priority, parent_task_id, depends_on, due_date,
estimated_hours, tags, related_entity_id,
related_cycle_id, related_insight_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING *
`, [
user.tenant_id,
user.user_id,
input.assignedToUserId || null,
pseudoId,
input.title,
input.description || null,
input.project,
input.milestone || null,
input.priority?.toLowerCase() || null,
input.parentTaskId || null,
input.dependsOn || [],
input.dueDate || null,
input.estimatedHours || null,
input.tags || [],
input.relatedEntityId || null,
input.relatedCycleId || null,
input.relatedInsightId || null
]);
const task = result.rows[0];
// Audit log
await query(`
INSERT INTO audit_log (tenant_id, user_id, action_type, details)
VALUES ($1, $2, 'task_created', $3)
`, [user.tenant_id, user.user_id, JSON.stringify({ taskId: task.task_id, title: task.title })]);
pubsub.publish('TASK_CREATED', { taskCreated: task });
return task;
},
completeTask: async (_, { taskId, actualHours }, context) => {
const result = await query(`
UPDATE tasks
SET status = 'completed',
completed_at = NOW(),
actual_hours = COALESCE($2, actual_hours),
updated_at = NOW()
WHERE task_id = $1
RETURNING *
`, [taskId, actualHours]);
const task = result.rows[0];
await query(`
INSERT INTO audit_log (tenant_id, user_id, action_type, details)
VALUES ($1, $2, 'task_completed', $3)
`, [context.user.tenant_id, context.user.user_id, JSON.stringify({ taskId })]);
pubsub.publish('TASK_COMPLETED', { taskCompleted: task });
return task;
},
bulkCreateTasks: async (_, { inputs }, context) => {
const tasks = [];
for (const input of inputs) {
const task = await resolvers.Mutation.createTask(_, { input }, context);
tasks.push(task);
}
return tasks;
},
},
Task: {
subtasks: async (parent) => {
const result = await query(
'SELECT * FROM tasks WHERE parent_task_id = $1 ORDER BY created_at',
[parent.task_id]
);
return result.rows;
},
dependencies: async (parent) => {
if (!parent.depends_on || parent.depends_on.length === 0) return [];
const result = await query(
'SELECT * FROM tasks WHERE task_id = ANY($1)',
[parent.depends_on]
);
return result.rows;
},
comments: async (parent) => {
const result = await query(
'SELECT * FROM task_comments WHERE task_id = $1 ORDER BY created_at DESC',
[parent.task_id]
);
return result.rows;
},
},
Subscription: {
taskCreated: {
subscribe: () => pubsub.asyncIterator(['TASK_CREATED']),
},
taskUpdated: {
subscribe: () => pubsub.asyncIterator(['TASK_UPDATED']),
},
taskCompleted: {
subscribe: () => pubsub.asyncIterator(['TASK_COMPLETED']),
},
},
};
File: taskpilot-graphql/server.js**
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import { createServer } from 'http';
import { readFileSync } from 'fs';
import admin from 'firebase-admin';
import { resolvers } from './resolvers.js';
import { query } from './db.js';
// Firebase init
admin.initializeApp({
credential: admin.credential.applicationDefault(),
});
// Load schema
const typeDefs = readFileSync('./schema.graphql', 'utf-8');
// Create schema
const schema = makeExecutableSchema({ typeDefs, resolvers });
// Express app
const app = express();
const httpServer = createServer(app);
// WebSocket server
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
useServer({ schema }, wsServer);
// Apollo Server
const server = new ApolloServer({ schema });
await server.start();
app.use(
'/graphql',
express.json(),
expressMiddleware(server, {
context: async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) throw new Error('Unauthorized');
const decodedToken = await admin.auth().verifyIdToken(token);
const result = await query(
'SELECT * FROM users WHERE email = $1',
[decodedToken.email]
);
if (!result.rows[0]) throw new Error('User not found');
return { user: result.rows[0] };
},
})
);
const PORT = process.env.PORT || 4000;
httpServer.listen(PORT, () => {
console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`);
console.log(`🔌 WebSocket ready at ws://localhost:${PORT}/graphql`);
});
4. Deployment Checklist
# 1. Run migration
psql $DATABASE_URL < migrations/20250102_add_tasks.sql
# 2. Install server dependencies
cd taskpilot-graphql
npm install
# 3. Set environment variables
export DATABASE_URL="postgresql://..."
export GOOGLE_APPLICATION_CREDENTIALS="path/to/firebase-key.json"
# 4. Start server
npm run dev
# 5. Test query
curl http://localhost:4000/graphql \
-H "Authorization: Bearer YOUR_FIREBASE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"{ projects { name taskCount } }"}'
5. React Client (Brief)
npm install @apollo/client graphql graphql-ws
Wrap app:
import { ApolloProvider } from '@apollo/client';
import { apolloClient } from './apollo-client';
<ApolloProvider client={apolloClient}>
<App />
</ApolloProvider>
Use in components:
const { data } = useQuery(gql`
query GetMyTasks {
myTasks(status: PENDING) {
taskId pseudoId title dueDate
}
}
`);
Ready to deploy. The architecture handles all 3 use cases: personal (now), team (soon), product feature (embedded in ChainAlign).
CLI Wrapper for AI Assistants
You need a CLI that wraps the GraphQL API so coding assistants can use familiar commands.
CLI Implementation
File: taskpilot-cli/task (executable)
#!/usr/bin/env node
import { ApolloClient, InMemoryCache, HttpLink, gql } from '@apollo/client/core';
import fetch from 'cross-fetch';
import { readFileSync } from 'fs';
import { homedir } from 'os';
const configPath = `${homedir()}/.taskpilot/config.json`;
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
const client = new ApolloClient({
link: new HttpLink({
uri: config.apiUrl || 'http://localhost:4000/graphql',
fetch,
headers: {
authorization: `Bearer ${config.token}`,
},
}),
cache: new InMemoryCache(),
});
const args = process.argv.slice(2);
const command = args[0];
// Task add project:X milestone:Y "description"
if (command === 'add') {
const project = args.find(a => a.startsWith('project:'))?.split(':')[1];
const milestone = args.find(a => a.startsWith('milestone:'))?.split(':')[1];
const depends = args.find(a => a.startsWith('depends:'))?.split(':')[1];
const priority = args.find(a => a.startsWith('priority:'))?.split(':')[1];
const due = args.find(a => a.startsWith('due:'))?.split(':')[1];
const title = args.find(a => !a.includes(':'));
const { data } = await client.mutate({
mutation: gql`
mutation CreateTask($input: CreateTaskInput!) {
createTask(input: $input) {
taskId pseudoId title
}
}
`,
variables: {
input: {
title,
project,
milestone,
priority: priority?.toUpperCase(),
dependsOn: depends ? [depends] : [],
dueDate: due,
},
},
});
console.log(`Created: ${data.createTask.pseudoId} ${data.createTask.title}`);
}
// Task list project:X
else if (command === 'list' || command === 'mlist') {
const project = args.find(a => a.startsWith('project:'))?.split(':')[1];
const milestone = args.find(a => a.startsWith('milestone:'))?.split(':')[1];
const { data } = await client.query({
query: gql`
query GetTasks($project: String, $milestone: String) {
tasks(project: $project, milestone: $milestone) {
pseudoId title status priority dueDate
}
}
`,
variables: { project, milestone },
});
console.log('ID Title Status Pri Due');
console.log('---------------------------------------------------------------');
data.tasks.forEach(t => {
const due = t.dueDate ? new Date(t.dueDate).toLocaleDateString() : '';
console.log(
`${(t.pseudoId || '').padEnd(8)} ${t.title.padEnd(30).slice(0,30)} ${t.status.padEnd(9)} ${(t.priority || '').padEnd(4)} ${due}`
);
});
}
// Task done <pseudo-id>
else if (command === 'done') {
const pseudoId = args[1];
// Get task by pseudoId first
const { data: taskData } = await client.query({
query: gql`
query GetTask($pseudoId: String!) {
taskByPseudoId(pseudoId: $pseudoId) {
taskId
}
}
`,
variables: { pseudoId },
});
await client.mutate({
mutation: gql`
mutation CompleteTask($taskId: ID!) {
completeTask(taskId: $taskId) {
pseudoId status
}
}
`,
variables: { taskId: taskData.taskByPseudoId.taskId },
});
console.log(`Completed task ${pseudoId}`);
}
// Task <pseudo-id> modify priority:high
else if (args[1] === 'modify') {
const pseudoId = args[0];
const updates = {};
args.slice(2).forEach(arg => {
const [key, value] = arg.split(':');
if (key === 'priority') updates.priority = value.toUpperCase();
if (key === 'status') updates.status = value.toUpperCase();
if (key === 'milestone') updates.milestone = value;
if (key === 'due') updates.dueDate = value;
});
const { data: taskData } = await client.query({
query: gql`
query GetTask($pseudoId: String!) {
taskByPseudoId(pseudoId: $pseudoId) { taskId }
}
`,
variables: { pseudoId },
});
await client.mutate({
mutation: gql`
mutation UpdateTask($taskId: ID!, $input: UpdateTaskInput!) {
updateTask(taskId: $taskId, input: $input) {
pseudoId
}
}
`,
variables: {
taskId: taskData.taskByPseudoId.taskId,
input: updates,
},
});
console.log(`Updated task ${pseudoId}`);
}
// Task export (JSON format for AI assistants)
else if (command === 'export') {
const { data } = await client.query({
query: gql`
query GetAllTasks {
tasks {
taskId pseudoId title description project milestone
status priority dueDate tags createdAt
}
}
`,
});
console.log(JSON.stringify(data.tasks, null, 2));
}
else {
console.log('Usage:');
console.log(' task add project:X milestone:Y "Task title"');
console.log(' task list [project:X] [milestone:Y]');
console.log(' task done <pseudo-id>');
console.log(' task <pseudo-id> modify priority:high');
console.log(' task export');
}
Install:
npm install @apollo/client graphql cross-fetch
chmod +x taskpilot-cli/task
ln -s $(pwd)/taskpilot-cli/task /usr/local/bin/task
Setup config:
mkdir -p ~/.taskpilot
cat > ~/.taskpilot/config.json << EOF
{
"apiUrl": "http://localhost:4000/graphql",
"token": "YOUR_FIREBASE_TOKEN"
}
EOF
For AI Coding Assistants
Documentation file for AI assistants:
## TaskPilot CLI Commands
### Create Task
task add project:chainalign milestone:M1 priority:high "Implement GraphQL schema"
### List Tasks
task list project:chainalign
task list milestone:M1
### Complete Task
task done M1-003
### Modify Task
task M1-003 modify priority:critical
task M1-003 modify status:in_progress
### Export (JSON for parsing)
task export > tasks.json
### Bulk Create (pipe JSON)
cat << EOF | task bulk-add
{"title": "Task 1", "project": "chainalign", "milestone": "M1"}
{"title": "Task 2", "project": "chainalign", "milestone": "M1"}
EOF
Architecture Summary
┌─────────────────┐
│ You (GUI) │
│ React + Apollo │
└────────┬────────┘
│
├─────────────┐
│ │
▼ ▼
┌─────────────┐ ┌──────────────┐
│ GraphQL │ │ CLI Wrapper │
│ Server │◄─┤ (for AIs) │
└──────┬──────┘ └──────────────┘
│
▼
┌─────────────┐
│ PostgreSQL │
└─────────────┘
Key points:
- GUI uses GraphQL directly (Apollo Client)
- CLI wraps GraphQL for AI assistants (familiar syntax)
- Both interfaces share same data/business logic
- Team members use GUI when they join
- AI assistants use CLI with same data
Token management:
Store Firebase token in ~/.taskpilot/config.json - refresh it periodically (tokens expire after 1 hour by default).
I'll create a TaskPilot mockup following ChainAlign's brand system.View TaskPilot mockup
Design Decisions
Following ChainAlign brand:
- Deep Navy (#0B1D3A) for headers and primary text
- Signal Green (#00D084) for CTAs and active states
- Inter font for UI, JetBrains Mono for IDs/data
- 8px spacing grid, 8px card border radius
- Clean, data-focused layout
Key features shown:
- Three-column layout (sidebar, main, details)
- Project → Milestone hierarchy
- Pseudo-IDs prominently displayed
- Status/priority badges with brand colors
- Activity timeline in right panel
- List/Board/Tree view toggles
Enterprise touches:
- Minimal decoration, maximum information density
- Monospace font for technical data (IDs, time estimates)
- Subtle hover states
- Clear visual hierarchy
The mockup is static HTML - ready to convert to React components using the same styling.
Link Documents to Tasks
Schema addition:
-- Add to tasks table
ALTER TABLE tasks ADD COLUMN related_documents JSONB DEFAULT '[]';
-- Example structure:
-- [
-- {"type": "fsd", "path": "/docs/M1-architecture-fsd.md", "status": "approved"},
-- {"type": "dev_summary", "path": "/docs/M1-dev-summary.md"},
-- {"type": "tdd", "path": "/docs/M1-tdd-summary.md"}
-- ]
-- Or link to existing documents table
ALTER TABLE tasks ADD COLUMN fsd_document_id UUID REFERENCES source_documents(id);
ALTER TABLE tasks ADD COLUMN dev_summary_id UUID REFERENCES source_documents(id);
ALTER TABLE tasks ADD COLUMN tdd_summary_id UUID REFERENCES source_documents(id);
Pattern for milestones:
CREATE TABLE milestone_documents (
milestone_id VARCHAR(50) PRIMARY KEY, -- "M1", "M2"
project VARCHAR(255),
fsd_document_id UUID REFERENCES source_documents(id),
dev_summary_id UUID REFERENCES source_documents(id),
tdd_summary_id UUID REFERENCES source_documents(id),
fsd_approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
GraphQL additions:
type Task {
# ... existing fields
fsdDocument: Document
devSummary: Document
tddSummary: Document
}
type Milestone {
name: String!
documents: MilestoneDocuments!
tasks: [Task!]!
completionStatus: MilestoneStatus!
}
type MilestoneStatus {
fsdApproved: Boolean!
tasksCompleted: Int!
tasksTotal: Int!
devSummaryExists: Boolean!
tddSummaryExists: Boolean!
}
UI mockup addition:
Milestone card shows:
- ✅ FSD Approved (Nov 10)
- 🏗️ Tasks: 7/12 complete
- 📝 Dev Summary (in progress)
- ⏳ TDD Summary (pending)
Workflow:
- Create milestone → auto-creates FSD placeholder
- Approve FSD → unlocks task creation
- Tasks link to specific FSD sections
- Complete tasks → auto-prompt for dev summary
- All tasks done → require TDD summary before milestone close
Best practice from Linear/Notion:
- Each task shows "📄 Linked docs" badge
- Click to view in side panel or open in editor
- Documents reference tasks by pseudo-ID (M1-003)
- Bidirectional sync keeps both updated
This makes your process auditable and professional for when investors/partners ask "show me your process."
Implementation Gap Problem
Root cause: FSD → actual code mapping breaks during interruptions.
Solution: Task manager as implementation ledger
-- Track actual implementation state
ALTER TABLE tasks ADD COLUMN implementation_status JSONB DEFAULT '{
"files_modified": [],
"fsd_sections_completed": [],
"last_checkpoint": null,
"verified": false
}';
-- Link task to FSD sections
ALTER TABLE tasks ADD COLUMN fsd_sections TEXT[];
-- Example: ["3.2.1 GraphQL Schema", "4.1 Database Migration"]
Auto-checkpoint on interruption:
// Pre-commit hook or IDE integration
function createCheckpoint(taskId) {
const modifiedFiles = getGitDiff();
const currentSection = getCurrentFSDSection(); // from cursor position
await updateTask(taskId, {
implementation_status: {
files_modified: modifiedFiles,
last_checkpoint: new Date(),
current_fsd_section: currentSection
}
});
}
Resume workflow:
task resume M1-003
# Shows:
# Last worked: 2 hours ago
# FSD section: 3.2.1 GraphQL Schema
# Files modified: schema.graphql, resolvers.js
# Next: Implement mutations (section 3.2.2)
Gap detection:
// Compare FSD checklist vs completed tasks
function findGaps(milestoneId) {
const fsdSections = parseFSD(milestoneId); // Extract all "[ ]" items
const completedSections = getCompletedTaskSections(milestoneId);
return fsdSections.filter(s => !completedSections.includes(s));
}
UI shows:
- ⚠️ FSD-Code Gap: Section 4.2 (Auth middleware) has no task
- 🔄 Interrupted Task: M1-003 (last worked 3 hours ago)
- ✅ Verified: M1-001 (code matches FSD)
This turns task manager into your "where was I?" and "what's missing?" tool.
FSD → Task Auto-Generation
Parser implementation:
// fsd-parser.js
import { readFileSync } from 'fs';
import { marked } from 'marked';
export function parseFSDToTasks(fsdPath, milestone, project) {
const content = readFileSync(fsdPath, 'utf-8');
const tokens = marked.lexer(content);
const tasks = [];
let currentSection = '';
let sectionDepth = 0;
tokens.forEach((token, idx) => {
// Track section hierarchy
if (token.type === 'heading') {
currentSection = token.text;
sectionDepth = token.depth;
}
// Extract implementation items
if (token.type === 'list') {
token.items.forEach(item => {
// Match patterns like:
// - [ ] Implement GraphQL schema
// - [ ] Create migration for tasks table
// - [ ] Add authentication middleware
const match = item.text.match(/^\[ \] (.+)/);
if (match) {
tasks.push({
title: match[1],
fsd_section: currentSection,
fsd_section_number: getSectionNumber(tokens, idx),
project,
milestone,
estimated_hours: extractEstimate(item.text),
});
}
});
}
});
return tasks;
}
function getSectionNumber(tokens, startIdx) {
// Walk backwards to find section number like "3.2.1"
for (let i = startIdx; i >= 0; i--) {
if (tokens[i].type === 'heading') {
const match = tokens[i].text.match(/^(\d+(?:\.\d+)*)/);
if (match) return match[1];
}
}
return null;
}
function extractEstimate(text) {
// Match patterns: (2h), (4 hours), etc.
const match = text.match(/\((\d+)\s*h(?:ours?)?\)/i);
return match ? parseInt(match[1]) : null;
}
CLI command:
task import-fsd docs/M1-architecture-fsd.md \
--project=chainalign \
--milestone=M1
# Output:
# ✓ Created M1-001: Implement GraphQL schema (Section 3.2.1)
# ✓ Created M1-002: Create migration for tasks table (Section 3.2.2)
# ✓ Created M1-003: Add authentication middleware (Section 4.1)
#
# 12 tasks created from FSD
Session checkpoint schema:
CREATE TABLE work_sessions (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
task_id UUID REFERENCES tasks(task_id),
milestone VARCHAR(50),
-- Context preservation
fsd_section TEXT,
files_in_progress TEXT[],
last_command TEXT,
cursor_position JSONB, -- {"file": "schema.graphql", "line": 42}
-- AI context
session_summary TEXT,
token_budget_used INT,
started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMPTZ,
ended_reason VARCHAR(50) -- 'completed', 'interrupted', 'token_limit', 'crash'
);
-- Auto-create on first task interaction
CREATE OR REPLACE FUNCTION start_work_session()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status = 'in_progress' AND OLD.status = 'pending' THEN
INSERT INTO work_sessions (user_id, task_id, milestone, fsd_section)
VALUES (NEW.updated_by_user_id, NEW.task_id, NEW.milestone, NEW.fsd_sections[1]);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Smart resume:
// resume-service.js
export async function getResumeContext(userId) {
const session = await query(`
SELECT
ws.*,
t.pseudo_id,
t.title,
t.fsd_sections,
t.implementation_status
FROM work_sessions ws
JOIN tasks t ON ws.task_id = t.task_id
WHERE ws.user_id = $1
AND ws.ended_at IS NULL
ORDER BY ws.started_at DESC
LIMIT 1
`, [userId]);
if (!session.rows[0]) return null;
const s = session.rows[0];
return {
task: {
id: s.pseudo_id,
title: s.title,
},
fsd_section: s.fsd_section,
files_modified: s.files_in_progress,
last_worked: formatDistance(s.started_at),
// Generate concise summary
summary: `Resume: ${s.pseudo_id} - ${s.title}
Working on FSD section: ${s.fsd_section}
Files: ${s.files_in_progress.join(', ')}
Last: ${s.session_summary || 'No summary available'}`,
// Smart next steps
suggested_next: await getSuggestedNextSteps(s.task_id, s.fsd_section),
};
}
async function getSuggestedNextSteps(taskId, currentSection) {
// Parse FSD to find next uncompleted section
const fsd = await getFSDForTask(taskId);
const sections = parseFSDSections(fsd);
const currentIdx = sections.findIndex(s => s.title === currentSection);
return sections[currentIdx + 1]?.title || 'Task completion';
}
CLI workflow:
# End of session (auto-triggered by Claude or manual)
task checkpoint M1-003 \
--summary "Implemented GraphQL schema, need to add resolvers" \
--files schema.graphql,resolvers.js
# Next session starts
task resume
# Output:
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Resume Session
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#
# Task: M1-003 - Add authentication middleware
# FSD Section: 4.1 Auth Middleware Implementation
# Last worked: 3 hours ago
#
# Files in progress:
# - auth-middleware.js
# - server.js
#
# Last session summary:
# "Implemented GraphQL schema, need to add resolvers"
#
# Suggested next:
# → Section 4.2: Token verification logic
#
# [Enter to continue | Ctrl+C to start fresh]
GraphQL additions:
type Query {
resumeContext: ResumeContext
sessionHistory(limit: Int): [WorkSession!]!
}
type ResumeContext {
task: Task
fsdSection: String
filesModified: [String!]!
lastWorked: String
summary: String
suggestedNext: String
}
type Mutation {
checkpointSession(
taskId: ID!
summary: String!
files: [String!]
): WorkSession!
importFSD(
fsdPath: String!
project: String!
milestone: String!
): [Task!]!
}
UI additions:
Top of TaskPilot dashboard:
┌─────────────────────────────────────────────────┐
│ 🔄 Resume Session │
│ │
│ M1-003: Add authentication middleware │
│ Section 4.1 • Last worked 3h ago │
│ │
│ Files: auth-middleware.js, server.js │
│ Next: Section 4.2 - Token verification │
│ │
│ [Resume →] [Start Fresh] │
└─────────────────────────────────────────────────┘
Token optimization:
Instead of feeding full FSD every session:
- Load only relevant section (4.1, not entire doc)
- Include last 3 checkpoints for continuity
- Summary = ~500 tokens vs 3000+ for full FSD
Implementation priority:
- FSD parser + import command (2 hours)
- Session checkpoints (2 hours)
- Resume context API (1 hour)
- UI integration (2 hours)
This solves your session continuity problem and makes the task manager your implementation memory.
TDD Workflow Integration
Your workflow is solid. The frustration is normal - tests often take 2-3x coding time initially, but save 10x later. Let me update the FSD to support your exact flow:
Schema additions:
-- Add TDD-specific fields to tasks
ALTER TABLE tasks ADD COLUMN tdd_status JSONB DEFAULT '{
"test_files": [],
"code_files": [],
"tests_passing": 0,
"tests_failing": 0,
"last_test_run": null,
"debugging_notes": []
}';
ALTER TABLE tasks ADD COLUMN git_commits TEXT[];
ALTER TABLE tasks ADD COLUMN test_coverage NUMERIC(5,2);
Enhanced task statuses:
-- More granular TDD workflow
ALTER TABLE tasks DROP CONSTRAINT valid_status;
ALTER TABLE tasks ADD CONSTRAINT valid_status CHECK (status IN (
'pending',
'writing_tests',
'writing_code',
'testing',
'debugging',
'code_review',
'completed',
'blocked'
));
Auto-checkpoint on test failure:
CREATE OR REPLACE FUNCTION checkpoint_on_test_failure()
RETURNS TRIGGER AS $$
BEGIN
IF (NEW.tdd_status->>'tests_failing')::int > 0
AND (OLD.tdd_status->>'tests_failing')::int = 0 THEN
-- Auto-create checkpoint when tests start failing
INSERT INTO work_sessions (
user_id, task_id, ended_reason,
session_summary, ended_at
) VALUES (
NEW.assigned_to_user_id,
NEW.task_id,
'test_failure',
format('Tests failing: %s/%s',
NEW.tdd_status->>'tests_failing',
(NEW.tdd_status->>'tests_passing')::int +
(NEW.tdd_status->>'tests_failing')::int
),
NOW()
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CLI commands for TDD:
# Start writing tests
task start-tests M1-003
# Status: pending → writing_tests
# Mark tests written, start code
task start-code M1-003
# Status: writing_tests → writing_code
# Run tests and record results
task test M1-003 --passed 5 --failed 2
# Auto-checkpoints if failures
# Updates tdd_status
# Mark complete (requires all tests passing)
task done M1-003 --commit abc123
# Verifies tests passing, links commit
GraphQL additions:
type Task {
tddStatus: TDDStatus!
gitCommits: [String!]!
testCoverage: Float
}
type TDDStatus {
testFiles: [String!]!
codeFiles: [String!]!
testsPassing: Int!
testsFailing: Int!
lastTestRun: DateTime
debuggingNotes: [String!]!
}
type Mutation {
startTests(taskId: ID!): Task!
startCode(taskId: ID!): Task!
recordTestRun(
taskId: ID!
passing: Int!
failing: Int!
output: String
): Task!
addDebuggingNote(taskId: ID!, note: String!): Task!
}
Updated FSD section (testing):
## 4.3 TDD Workflow Support
- [ ] Add tdd_status column (1h)
- [ ] Implement status transitions (2h) [priority: high]
- [ ] Auto-checkpoint on test failures (1h)
- [ ] CLI test commands (2h)
- [ ] Test coverage tracking (1h)
Testing time reduction tips:
- Parallel tests: Run fast unit tests first, slow integration tests only when units pass
- Test templates: Create reusable test patterns for common cases
- Snapshot failures: CLI shows exact test output, no context switch to IDE
- Debug notes:
task debug M1-003 "Issue: auth middleware not firing"- searchable later