← back to Letsbegin
components/RalphConverterStep.tsx
473 lines
'use client'
import { useState } from 'react'
interface UserStory {
id: string
title: string
description: string
acceptanceCriteria: string[]
priority: number
passes: boolean
notes: string
}
interface RalphData {
project: string
branchName: string
description: string
userStories: UserStory[]
}
interface Props {
prdMarkdown: string
projectName: string
onConvert: (data: RalphData) => void
ralphData: RalphData | null
validationResults: Record<string, boolean>
setValidationResults: (results: Record<string, boolean>) => void
onBack: () => void
}
const validationChecks = [
{ id: 'storySize', label: 'Each story is small enough (2-3 sentence description)' },
{ id: 'ordering', label: 'Stories ordered by dependency (schema → backend → UI)' },
{ id: 'typecheck', label: 'Every story has "Typecheck passes" criterion' },
{ id: 'browser', label: 'UI stories have "Verify in browser" criterion' },
{ id: 'noDeps', label: 'No story depends on a later story' },
{ id: 'verifiable', label: 'Acceptance criteria are verifiable (not vague)' },
]
export default function RalphConverterStep({
prdMarkdown,
projectName,
onConvert,
ralphData,
validationResults,
setValidationResults,
onBack
}: Props) {
const [isConverting, setIsConverting] = useState(false)
const [editingStory, setEditingStory] = useState<string | null>(null)
const [localRalphData, setLocalRalphData] = useState<RalphData | null>(ralphData)
const [validationIssues, setValidationIssues] = useState<string[]>([])
const [archivedTo, setArchivedTo] = useState<string | null>(null)
const [isFixing, setIsFixing] = useState(false)
const [fixedMessage, setFixedMessage] = useState<string | null>(null)
// Determine story type based on title/description
function getStoryType(story: UserStory): 'schema' | 'backend' | 'ui' | 'other' {
const text = `${story.title} ${story.description}`.toLowerCase()
if (text.includes('schema') || text.includes('database') || text.includes('model') || text.includes('migration')) {
return 'schema'
}
if (text.includes('api') || text.includes('endpoint') || text.includes('backend') || text.includes('service') || text.includes('server')) {
return 'backend'
}
if (text.includes('ui') || text.includes('component') || text.includes('page') || text.includes('form') || text.includes('button') || text.includes('display') || text.includes('view') || text.includes('dashboard') || text.includes('interface')) {
return 'ui'
}
return 'other'
}
// Auto-fix all validation issues
function handleFixIssues() {
if (!localRalphData) return
setIsFixing(true)
setFixedMessage(null)
try {
let fixedStories = localRalphData.userStories.map(story => ({ ...story, acceptanceCriteria: [...story.acceptanceCriteria] }))
const fixedIssues: string[] = []
// Fix 1: Add "Verify in browser" to UI stories that don't have it
fixedStories = fixedStories.map(story => {
const storyType = getStoryType(story)
if (storyType === 'ui') {
const hasBrowserCriterion = story.acceptanceCriteria.some(c =>
c.toLowerCase().includes('verify in browser') ||
c.toLowerCase().includes('browser verification') ||
c.toLowerCase().includes('visually verify')
)
if (!hasBrowserCriterion) {
fixedIssues.push(`Added "Verify in browser" to ${story.id}`)
return {
...story,
acceptanceCriteria: [...story.acceptanceCriteria, 'Verify in browser that the UI displays correctly']
}
}
}
return story
})
// Fix 2: Add "Typecheck passes" to stories that don't have it
fixedStories = fixedStories.map(story => {
const hasTypecheck = story.acceptanceCriteria.some(c =>
c.toLowerCase().includes('typecheck') ||
c.toLowerCase().includes('type check') ||
c.toLowerCase().includes('typescript')
)
if (!hasTypecheck) {
fixedIssues.push(`Added "Typecheck passes" to ${story.id}`)
return {
...story,
acceptanceCriteria: [...story.acceptanceCriteria, 'Typecheck passes']
}
}
return story
})
// Fix 3: Reorder stories by type (schema → backend → other → ui)
const typeOrder = { schema: 0, backend: 1, other: 2, ui: 3 }
const originalOrder = fixedStories.map(s => s.id).join(',')
fixedStories.sort((a, b) => {
const typeA = getStoryType(a)
const typeB = getStoryType(b)
return typeOrder[typeA] - typeOrder[typeB]
})
const newOrder = fixedStories.map(s => s.id).join(',')
if (originalOrder !== newOrder) {
fixedIssues.push('Reordered stories: schema → backend → other → UI')
}
// Update priorities after reordering
fixedStories = fixedStories.map((story, idx) => ({
...story,
priority: idx + 1
}))
// Create the new ralph data
const newRalphData = { ...localRalphData, userStories: fixedStories }
// Update state
setLocalRalphData(newRalphData)
// Update validation results - mark all fixable ones as true
const newValidationResults = {
...validationResults,
browser: true,
typecheck: true,
ordering: true,
noDeps: true
}
setValidationResults(newValidationResults)
// Clear issues that were fixed - use more inclusive pattern matching
const remainingIssues = validationIssues.filter(issue => {
const lowerIssue = issue.toLowerCase()
// Remove issues we just fixed
if (lowerIssue.includes('verify in browser') || lowerIssue.includes('browser')) return false
if (lowerIssue.includes('story ordering') || lowerIssue.includes('should come before')) return false
if (lowerIssue.includes('typecheck')) return false
return true
})
setValidationIssues(remainingIssues)
// Show what was fixed
if (fixedIssues.length > 0) {
setFixedMessage(`Fixed ${fixedIssues.length} issue(s): ${fixedIssues.join(', ')}`)
console.log('Fixed issues:', fixedIssues)
} else {
setFixedMessage('No auto-fixable issues found')
}
} catch (err) {
console.error('Error fixing issues:', err)
setFixedMessage('Error fixing issues: ' + (err as Error).message)
} finally {
setIsFixing(false)
}
}
async function handleConvert() {
setIsConverting(true)
setValidationIssues([])
try {
const res = await fetch('/api/ralph/convert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prdMarkdown, projectName })
})
const data = await res.json()
if (data.ralphData) {
setLocalRalphData(data.ralphData)
// Use validation results from API
if (data.validation) {
setValidationIssues(data.validation.issues || [])
// Map validation issues to checkbox states
const results: Record<string, boolean> = {}
const issues = data.validation.issues || []
// Check each validation type based on issue patterns
results['storySize'] = !issues.some((i: string) => i.includes('Description too long'))
results['typecheck'] = !issues.some((i: string) => i.includes('Typecheck passes'))
results['browser'] = !issues.some((i: string) => i.includes('Verify in browser'))
results['verifiable'] = !issues.some((i: string) => i.includes('Vague criterion'))
results['ordering'] = !issues.some((i: string) => i.includes('Story ordering'))
results['noDeps'] = !issues.some((i: string) => i.includes('depends on a later'))
setValidationResults(results)
}
if (data.archivedTo) {
setArchivedTo(data.archivedTo)
}
}
} catch (err) {
console.error('Failed to convert:', err)
} finally {
setIsConverting(false)
}
}
function handleSaveAndNext() {
if (localRalphData) {
onConvert(localRalphData)
}
}
function moveStory(index: number, direction: 'up' | 'down') {
if (!localRalphData) return
const newStories = [...localRalphData.userStories]
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= newStories.length) return
[newStories[index], newStories[targetIndex]] = [newStories[targetIndex], newStories[index]]
// Update priorities
newStories.forEach((story, i) => { story.priority = i + 1 })
setLocalRalphData({ ...localRalphData, userStories: newStories })
}
function deleteStory(id: string) {
if (!localRalphData) return
const newStories = localRalphData.userStories.filter(s => s.id !== id)
newStories.forEach((story, i) => { story.priority = i + 1 })
setLocalRalphData({ ...localRalphData, userStories: newStories })
}
const allValid = Object.values(validationResults).every(v => v)
return (
<div className="space-y-6">
{/* Side by Side View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* PRD Input */}
<div className="card">
<div className="p-4 border-b border-[var(--border)] flex items-center gap-2">
<span>📄</span>
<h3 className="font-medium">PRD (Input)</h3>
</div>
<div className="p-4 max-h-80 overflow-y-auto">
<pre className="text-sm whitespace-pre-wrap">{prdMarkdown || 'No PRD generated yet'}</pre>
</div>
</div>
{/* prd.json Output */}
<div className="card">
<div className="p-4 border-b border-[var(--border)] flex items-center justify-between">
<div className="flex items-center gap-2">
<span>📦</span>
<h3 className="font-medium">prd.json (Output)</h3>
</div>
{!localRalphData && (
<button
onClick={handleConvert}
className="btn-primary text-sm"
disabled={isConverting || !prdMarkdown}
>
{isConverting ? 'Converting...' : 'Convert →'}
</button>
)}
</div>
<div className="p-4 max-h-80 overflow-y-auto">
{localRalphData ? (
<pre className="code-block text-xs">
{JSON.stringify(localRalphData, null, 2)}
</pre>
) : (
<p className="text-[var(--foreground-tertiary)] text-sm">
Click "Convert" to generate prd.json
</p>
)}
</div>
</div>
</div>
{/* Archive Notice */}
{archivedTo && (
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-blue-400 text-sm">
Previous prd.json archived to: {archivedTo}
</div>
)}
{/* Validation Issues */}
{validationIssues.length > 0 && (
<div className="card p-4 border-[var(--warning)]">
<h3 className="font-medium mb-3 flex items-center gap-2 text-[var(--warning)]">
<span>⚠️</span>
Validation Issues ({validationIssues.length})
</h3>
<ul className="space-y-2 text-sm">
{validationIssues.map((issue, idx) => (
<li key={idx} className="flex items-start gap-2 text-[var(--foreground-secondary)]">
<span className="text-[var(--warning)]">•</span>
{issue}
</li>
))}
</ul>
<div className="mt-4 flex items-center gap-3">
<button
onClick={handleFixIssues}
className="btn-primary text-sm"
disabled={isFixing}
>
{isFixing ? 'Fixing...' : '🔧 Fix Validation Issues'}
</button>
<span className="text-xs text-[var(--foreground-tertiary)]">
or manually check the boxes below to proceed
</span>
</div>
{fixedMessage && (
<div className="mt-3 p-2 bg-green-500/20 border border-green-500/30 rounded text-green-400 text-sm">
✅ {fixedMessage}
</div>
)}
</div>
)}
{/* Validation Checklist */}
{localRalphData && (
<div className="card p-4">
<h3 className="font-medium mb-4 flex items-center gap-2">
<span>✓</span>
Validation Checklist
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{validationChecks.map((check) => (
<div
key={check.id}
className={`checkbox-option ${validationResults[check.id] ? 'checked' : ''}`}
onClick={() => setValidationResults({
...validationResults,
[check.id]: !validationResults[check.id]
})}
>
<div className={`checkbox-box ${validationResults[check.id] ? 'checked' : ''}`}>
{validationResults[check.id] && (
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className="text-sm">{check.label}</span>
</div>
))}
</div>
</div>
)}
{/* Story Editor */}
{localRalphData && (
<div className="card p-4">
<h3 className="font-medium mb-4 flex items-center gap-2">
<span>📝</span>
Edit Stories (Drag to Reorder)
</h3>
<div className="space-y-2">
{localRalphData.userStories.map((story, index) => (
<div
key={story.id}
className="flex items-center gap-3 p-3 bg-[var(--surface-alt)] rounded-lg border border-[var(--border)]"
>
{/* Drag Handle */}
<span className="text-[var(--foreground-tertiary)] cursor-move">≡</span>
{/* Story Info */}
<div className="flex-1">
<div className="font-medium text-sm">{story.id}: {story.title}</div>
<div className="text-xs text-[var(--foreground-tertiary)]">
Priority: {story.priority} | Criteria: {story.acceptanceCriteria.length}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => moveStory(index, 'up')}
disabled={index === 0}
className="p-1 hover:bg-[var(--surface-elevated)] rounded disabled:opacity-30"
>
↑
</button>
<button
onClick={() => moveStory(index, 'down')}
disabled={index === localRalphData.userStories.length - 1}
className="p-1 hover:bg-[var(--surface-elevated)] rounded disabled:opacity-30"
>
↓
</button>
<button
onClick={() => setEditingStory(story.id)}
className="p-1 hover:bg-[var(--surface-elevated)] rounded text-[var(--primary)]"
>
✏️
</button>
<button
onClick={() => deleteStory(story.id)}
className="p-1 hover:bg-[var(--surface-elevated)] rounded text-[var(--error)]"
>
🗑️
</button>
</div>
</div>
))}
</div>
<div className="mt-4 flex items-center gap-3">
<button className="btn-secondary text-sm">+ Add Story</button>
<button className="btn-secondary text-sm">🔀 Split Selected</button>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between p-4 card">
<button onClick={onBack} className="btn-secondary">
← Back to PRD
</button>
<div className="flex items-center gap-3">
<button
onClick={() => {
if (localRalphData) {
navigator.clipboard.writeText(JSON.stringify(localRalphData, null, 2))
alert('prd.json copied to clipboard!')
}
}}
className="btn-secondary"
disabled={!localRalphData}
>
Copy prd.json
</button>
<button
onClick={() => {
if (!localRalphData) return
if (!allValid) {
const proceed = confirm('Some validation checks are not passing. Proceed anyway?')
if (!proceed) return
}
handleSaveAndNext()
}}
className="btn-primary"
disabled={!localRalphData}
>
Next: Ralphy Boy →
</button>
</div>
</div>
</div>
)
}