From 3e0b849212369c3e9f50f7569ead64e6c0c6fac5 Mon Sep 17 00:00:00 2001 From: Isaiah Date: Fri, 15 May 2026 11:45:07 -0400 Subject: [PATCH] feat: AI Project Planner in global + Create dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds '✨ AI Planner' option to the + Create menu. User describes what they want to build, picks sprint count/duration/team size, and Claude generates a full multi-sprint project plan with tasks. Preview shows collapsible sprint cards before committing; creates all sprints and work items in one shot. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 25 +- src/api/ai.ts | 83 +++++ src/components/GlobalCreateButton.tsx | 9 +- .../dialogs/AiProjectPlannerDialog.tsx | 283 ++++++++++++++++++ 4 files changed, 395 insertions(+), 5 deletions(-) create mode 100644 src/components/dialogs/AiProjectPlannerDialog.tsx diff --git a/src/App.tsx b/src/App.tsx index 6942c54..6f4a48a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import GlobalCreateButton from './components/GlobalCreateButton' import WorkItemDialog from './components/dialogs/WorkItemDialog' import NewSprintDialog from './components/dialogs/NewSprintDialog' import NewProjectDialog from './components/dialogs/NewProjectDialog' +import AiProjectPlannerDialog from './components/dialogs/AiProjectPlannerDialog' export default function App() { const [token, setToken] = useState(loadToken) @@ -22,7 +23,7 @@ export default function App() { const [tab, setTab] = useState('board') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [createType, setCreateType] = useState<'item' | 'sprint' | 'project' | null>(null) + const [createType, setCreateType] = useState<'item' | 'sprint' | 'project' | 'ai' | null>(null) // Load projects when user signs in useEffect(() => { @@ -125,6 +126,19 @@ export default function App() { if (projectId === selectedId) setSprints(s => [...s, sprint]) } + async function handleAiSprintCreate(data: Omit, projectId: string): Promise { + if (!token) throw new Error('Not authenticated') + const sprint = await api.createSprint(token, projectId, data) + if (projectId === selectedId) setSprints(s => [...s, sprint]) + return sprint + } + + async function handleAiItemCreate(data: Omit, projectId: string) { + if (!token) return + const item = await api.createWorkItem(token, projectId, data) + if (projectId === selectedId) setWorkItems(w => [...w, item]) + } + return (
{createType === 'item' && selectedId && ( @@ -149,6 +163,15 @@ export default function App() { onClose={() => setCreateType(null)} /> )} + {createType === 'ai' && selectedId && ( + setCreateType(null)} + /> + )} { + const apiKey = import.meta.env.VITE_ANTHROPIC_API_KEY + if (!apiKey) throw new Error('VITE_ANTHROPIC_API_KEY is not set in your .env file') + + const client = new Anthropic({ apiKey, dangerouslyAllowBrowser: true }) + + const itemsPerSprint = Math.max(3, Math.min(12, input.teamSize * 2)) + const sprintDays = input.sprintDurationWeeks * 7 + + const sprintDates = Array.from({ length: input.sprintCount }, (_, i) => ({ + startDate: addDays(input.startDate, i * sprintDays), + endDate: addDays(input.startDate, (i + 1) * sprintDays - 1), + })) + + const prompt = `You are a software project manager. Create a complete project plan for the following goal: + +"${input.goal}" + +Project name: ${input.projectName} +Number of sprints: ${input.sprintCount} +Sprint duration: ${input.sprintDurationWeeks} week(s) each +Team size: ${input.teamSize} person(s) +Target items per sprint: ~${itemsPerSprint} + +Sprint schedule: +${sprintDates.map((d, i) => ` Sprint ${i + 1}: ${d.startDate} → ${d.endDate}`).join('\n')} + +Structure the work logically: foundation and setup early, core features in the middle, polish and testing at the end. +Distribute work evenly. Each sprint should have ~${itemsPerSprint} items. + +Respond with ONLY valid JSON — no markdown fences, no explanation: + +{ + "sprints": [ + { + "sprint": { + "name": "string (short, action-oriented sprint name)", + "description": "string (1-2 sentence sprint goal)", + "startDate": "YYYY-MM-DD", + "endDate": "YYYY-MM-DD", + "status": "planned" + }, + "workItems": [ + { + "title": "string (concise task title)", + "description": "string (1 sentence explaining the task)", + "type": "feature" | "bug" | "task", + "priority": "high" | "medium" | "low", + "storyPoints": number + } + ] + } + ] +}` + + const message = await client.messages.create({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 4096, + messages: [{ role: 'user', content: prompt }], + }) + + const text = message.content.find(b => b.type === 'text')?.text ?? '' + + try { + return JSON.parse(text) as AiProjectPlan + } catch { + throw new Error('Claude returned invalid JSON. Try again.') + } +} diff --git a/src/components/GlobalCreateButton.tsx b/src/components/GlobalCreateButton.tsx index a928e47..e390e31 100644 --- a/src/components/GlobalCreateButton.tsx +++ b/src/components/GlobalCreateButton.tsx @@ -1,15 +1,16 @@ import { useState, useRef, useEffect } from 'react' -type CreateType = 'item' | 'sprint' | 'project' +type CreateType = 'item' | 'sprint' | 'project' | 'ai' interface Props { onCreate: (type: CreateType) => void } const OPTIONS: { type: CreateType; label: string; description: string }[] = [ - { type: 'item', label: 'Work Item', description: 'Task, feature, or bug' }, - { type: 'sprint', label: 'Sprint', description: 'Timebox for a set of work' }, - { type: 'project', label: 'Project', description: 'New calendar-backed project' }, + { type: 'item', label: 'Work Item', description: 'Task, feature, or bug' }, + { type: 'sprint', label: 'Sprint', description: 'Timebox for a set of work' }, + { type: 'project', label: 'Project', description: 'New calendar-backed project' }, + { type: 'ai', label: '✨ AI Planner', description: 'Describe a goal, Claude plans the work' }, ] export default function GlobalCreateButton({ onCreate }: Props) { diff --git a/src/components/dialogs/AiProjectPlannerDialog.tsx b/src/components/dialogs/AiProjectPlannerDialog.tsx new file mode 100644 index 0000000..381f945 --- /dev/null +++ b/src/components/dialogs/AiProjectPlannerDialog.tsx @@ -0,0 +1,283 @@ +import { useState } from 'react' +import type { Project, Sprint, WorkItem } from '../../types' +import { generateProjectPlan, type AiProjectPlan } from '../../api/ai' + +const TYPE_ICON: Record = { feature: '✦', bug: '⚑', task: '◈' } +const PRIORITY_COLOR: Record = { high: '#f87171', medium: '#fbbf24', low: '#94a3b8' } + +interface Props { + projects: Project[] + defaultProjectId: string + onCreateSprint: (data: Omit, projectId: string) => Promise + onCreateItem: (data: Omit, projectId: string) => Promise + onClose: () => void +} + +type Step = 'form' | 'generating' | 'preview' | 'creating' + +function today(): string { + return new Date().toISOString().slice(0, 10) +} + +export default function AiProjectPlannerDialog({ + projects, + defaultProjectId, + onCreateSprint, + onCreateItem, + onClose, +}: Props) { + const [step, setStep] = useState('form') + const [projectId, setProjectId] = useState(defaultProjectId) + const [goal, setGoal] = useState('') + const [startDate, setStartDate] = useState(today()) + const [sprintCount, setSprintCount] = useState(3) + const [sprintDurationWeeks, setSprintDurationWeeks] = useState(2) + const [teamSize, setTeamSize] = useState(2) + const [plan, setPlan] = useState(null) + const [expanded, setExpanded] = useState(0) + const [error, setError] = useState(null) + const [progress, setProgress] = useState('') + + const selectedProject = projects.find(p => p.id === projectId) + + async function handleGenerate(e: React.FormEvent) { + e.preventDefault() + setError(null) + setStep('generating') + try { + const result = await generateProjectPlan({ + projectName: selectedProject?.name ?? 'Project', + goal, + startDate, + sprintCount, + sprintDurationWeeks, + teamSize, + }) + setPlan(result) + setExpanded(0) + setStep('preview') + } catch (err) { + setError(err instanceof Error ? err.message : 'Generation failed') + setStep('form') + } + } + + async function handleCreate() { + if (!plan) return + setStep('creating') + const totalItems = plan.sprints.reduce((n, s) => n + s.workItems.length, 0) + try { + for (let i = 0; i < plan.sprints.length; i++) { + const { sprint: sprintData, workItems } = plan.sprints[i] + setProgress(`Creating sprint ${i + 1} of ${plan.sprints.length}…`) + const sprint = await onCreateSprint(sprintData, projectId) + setProgress(`Adding ${workItems.length} items to sprint ${i + 1}…`) + await Promise.all( + workItems.map(item => + onCreateItem( + { ...item, sprintId: sprint.id, status: 'new', assignee: '', deadline: '' }, + projectId + ) + ) + ) + } + setProgress(`Created ${plan.sprints.length} sprints and ${totalItems} work items.`) + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create plan') + setStep('preview') + } + } + + return ( +
+
e.stopPropagation()}> +
+

✨ AI Project Planner

+ +
+ + {/* ── Step 1: Form ── */} + {step === 'form' && ( +
+ {error &&

{error}

} + + + +