Implement roadmap feature management kanban with drag-and-drop support

This commit is contained in:
AndyMik90
2025-12-16 17:49:35 +01:00
parent 131ec4cb62
commit 9403230ef0
12 changed files with 2928 additions and 25 deletions
+1213 -2
View File
File diff suppressed because it is too large Load Diff
@@ -224,6 +224,70 @@ export function registerRoadmapHandlers(
}
);
// ============================================
// Roadmap Save (full state persistence for drag-and-drop)
// ============================================
ipcMain.handle(
IPC_CHANNELS.ROADMAP_SAVE,
async (
_,
projectId: string,
features: RoadmapFeature[]
): Promise<IPCResult> => {
const project = projectStore.getProject(projectId);
if (!project) {
return { success: false, error: 'Project not found' };
}
const roadmapPath = path.join(
project.path,
AUTO_BUILD_PATHS.ROADMAP_DIR,
AUTO_BUILD_PATHS.ROADMAP_FILE
);
if (!existsSync(roadmapPath)) {
return { success: false, error: 'Roadmap not found' };
}
try {
const content = readFileSync(roadmapPath, 'utf-8');
const roadmap = JSON.parse(content);
// Transform camelCase features back to snake_case for JSON file
roadmap.features = features.map((feature) => ({
id: feature.id,
title: feature.title,
description: feature.description,
rationale: feature.rationale || '',
priority: feature.priority,
complexity: feature.complexity,
impact: feature.impact,
phase_id: feature.phaseId,
dependencies: feature.dependencies || [],
status: feature.status,
acceptance_criteria: feature.acceptanceCriteria || [],
user_stories: feature.userStories || [],
linked_spec_id: feature.linkedSpecId,
competitor_insight_ids: feature.competitorInsightIds
}));
// Update metadata timestamp
roadmap.metadata = roadmap.metadata || {};
roadmap.metadata.updated_at = new Date().toISOString();
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2));
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to save roadmap'
};
}
}
);
ipcMain.handle(
IPC_CHANNELS.ROADMAP_UPDATE_FEATURE,
async (
@@ -47,6 +47,7 @@ import type {
export interface AgentAPI {
// Roadmap Operations
getRoadmap: (projectId: string) => Promise<IPCResult<Roadmap | null>>;
saveRoadmap: (projectId: string, roadmap: Roadmap) => Promise<IPCResult>;
generateRoadmap: (projectId: string) => void;
refreshRoadmap: (projectId: string) => void;
updateFeatureStatus: (
@@ -173,6 +174,9 @@ export const createAgentAPI = (): AgentAPI => ({
getRoadmap: (projectId: string): Promise<IPCResult<Roadmap | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.ROADMAP_GET, projectId),
saveRoadmap: (projectId: string, roadmap: Roadmap): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.ROADMAP_SAVE, projectId, roadmap),
generateRoadmap: (projectId: string): void =>
ipcRenderer.send(IPC_CHANNELS.ROADMAP_GENERATE, projectId),
@@ -0,0 +1,634 @@
/**
* Unit tests for Roadmap Store
* Tests Zustand store for roadmap state management including drag-and-drop actions
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useRoadmapStore, getFeaturesByPhase, getFeaturesByPriority, getFeatureStats } from '../stores/roadmap-store';
import type {
Roadmap,
RoadmapFeature,
RoadmapPhase,
RoadmapFeaturePriority,
RoadmapFeatureStatus
} from '../../shared/types';
// Helper to create test features
function createTestFeature(overrides: Partial<RoadmapFeature> = {}): RoadmapFeature {
return {
id: `feature-${Date.now()}-${Math.random().toString(36).substring(7)}`,
title: 'Test Feature',
description: 'Test description',
rationale: 'Test rationale',
priority: 'should' as RoadmapFeaturePriority,
complexity: 'medium',
impact: 'medium',
phaseId: 'phase-1',
dependencies: [],
status: 'idea' as RoadmapFeatureStatus,
acceptanceCriteria: ['Test criteria'],
userStories: ['As a user, I want to test'],
...overrides
};
}
// Helper to create test phases
function createTestPhase(overrides: Partial<RoadmapPhase> = {}): RoadmapPhase {
return {
id: `phase-${Date.now()}-${Math.random().toString(36).substring(7)}`,
name: 'Test Phase',
description: 'Test phase description',
order: 1,
status: 'planned',
features: [],
milestones: [],
...overrides
};
}
// Helper to create test roadmap
function createTestRoadmap(overrides: Partial<Roadmap> = {}): Roadmap {
return {
id: 'roadmap-1',
projectId: 'project-1',
projectName: 'Test Project',
version: '1.0.0',
vision: 'Test vision',
targetAudience: {
primary: 'Developers',
secondary: ['DevOps']
},
phases: [
createTestPhase({ id: 'phase-1', name: 'Phase 1', order: 1 }),
createTestPhase({ id: 'phase-2', name: 'Phase 2', order: 2 }),
createTestPhase({ id: 'phase-3', name: 'Phase 3', order: 3 })
],
features: [],
status: 'draft',
createdAt: new Date(),
updatedAt: new Date(),
...overrides
};
}
describe('Roadmap Store', () => {
beforeEach(() => {
// Reset store to initial state before each test
useRoadmapStore.setState({
roadmap: null,
competitorAnalysis: null,
generationStatus: {
phase: 'idle',
progress: 0,
message: ''
}
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('setRoadmap', () => {
it('should set roadmap', () => {
const roadmap = createTestRoadmap();
useRoadmapStore.getState().setRoadmap(roadmap);
expect(useRoadmapStore.getState().roadmap).toBeDefined();
expect(useRoadmapStore.getState().roadmap?.id).toBe('roadmap-1');
});
it('should clear roadmap with null', () => {
useRoadmapStore.setState({ roadmap: createTestRoadmap() });
useRoadmapStore.getState().setRoadmap(null);
expect(useRoadmapStore.getState().roadmap).toBeNull();
});
});
describe('reorderFeatures', () => {
it('should reorder features within a phase', () => {
const features = [
createTestFeature({ id: 'feature-1', phaseId: 'phase-1', title: 'Feature 1' }),
createTestFeature({ id: 'feature-2', phaseId: 'phase-1', title: 'Feature 2' }),
createTestFeature({ id: 'feature-3', phaseId: 'phase-1', title: 'Feature 3' })
];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
// Reorder: move feature-3 to the top
useRoadmapStore.getState().reorderFeatures('phase-1', ['feature-3', 'feature-1', 'feature-2']);
const state = useRoadmapStore.getState();
const phase1Features = state.roadmap?.features.filter((f) => f.phaseId === 'phase-1') || [];
expect(phase1Features).toHaveLength(3);
expect(phase1Features[0].id).toBe('feature-3');
expect(phase1Features[1].id).toBe('feature-1');
expect(phase1Features[2].id).toBe('feature-2');
});
it('should not affect features in other phases', () => {
const features = [
createTestFeature({ id: 'feature-1', phaseId: 'phase-1' }),
createTestFeature({ id: 'feature-2', phaseId: 'phase-1' }),
createTestFeature({ id: 'feature-3', phaseId: 'phase-2' }),
createTestFeature({ id: 'feature-4', phaseId: 'phase-2' })
];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
// Reorder phase-1 features only
useRoadmapStore.getState().reorderFeatures('phase-1', ['feature-2', 'feature-1']);
const state = useRoadmapStore.getState();
const phase2Features = state.roadmap?.features.filter((f) => f.phaseId === 'phase-2') || [];
// Phase 2 features should be unchanged
expect(phase2Features).toHaveLength(2);
expect(phase2Features.map((f) => f.id)).toContain('feature-3');
expect(phase2Features.map((f) => f.id)).toContain('feature-4');
});
it('should update updatedAt timestamp', () => {
const originalDate = new Date('2024-01-01');
const roadmap = createTestRoadmap({
features: [
createTestFeature({ id: 'feature-1', phaseId: 'phase-1' }),
createTestFeature({ id: 'feature-2', phaseId: 'phase-1' })
],
updatedAt: originalDate
});
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().reorderFeatures('phase-1', ['feature-2', 'feature-1']);
const state = useRoadmapStore.getState();
expect(state.roadmap?.updatedAt.getTime()).toBeGreaterThan(originalDate.getTime());
});
it('should handle empty feature array', () => {
const roadmap = createTestRoadmap({ features: [] });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().reorderFeatures('phase-1', []);
expect(useRoadmapStore.getState().roadmap?.features).toHaveLength(0);
});
it('should handle non-existent feature IDs gracefully', () => {
const features = [
createTestFeature({ id: 'feature-1', phaseId: 'phase-1' }),
createTestFeature({ id: 'feature-2', phaseId: 'phase-1' })
];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
// Try to reorder with a non-existent ID - it should be filtered out
useRoadmapStore.getState().reorderFeatures('phase-1', ['feature-2', 'nonexistent', 'feature-1']);
const state = useRoadmapStore.getState();
const phase1Features = state.roadmap?.features.filter((f) => f.phaseId === 'phase-1') || [];
expect(phase1Features).toHaveLength(2);
});
it('should do nothing if roadmap is null', () => {
useRoadmapStore.setState({ roadmap: null });
useRoadmapStore.getState().reorderFeatures('phase-1', ['feature-1', 'feature-2']);
expect(useRoadmapStore.getState().roadmap).toBeNull();
});
});
describe('updateFeaturePhase', () => {
it('should move feature to a different phase', () => {
const features = [
createTestFeature({ id: 'feature-1', phaseId: 'phase-1' }),
createTestFeature({ id: 'feature-2', phaseId: 'phase-1' })
];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().updateFeaturePhase('feature-1', 'phase-2');
const state = useRoadmapStore.getState();
const movedFeature = state.roadmap?.features.find((f) => f.id === 'feature-1');
expect(movedFeature?.phaseId).toBe('phase-2');
});
it('should not affect other features', () => {
const features = [
createTestFeature({ id: 'feature-1', phaseId: 'phase-1' }),
createTestFeature({ id: 'feature-2', phaseId: 'phase-1' }),
createTestFeature({ id: 'feature-3', phaseId: 'phase-2' })
];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().updateFeaturePhase('feature-1', 'phase-3');
const state = useRoadmapStore.getState();
// Other features should remain in their original phases
expect(state.roadmap?.features.find((f) => f.id === 'feature-2')?.phaseId).toBe('phase-1');
expect(state.roadmap?.features.find((f) => f.id === 'feature-3')?.phaseId).toBe('phase-2');
});
it('should update updatedAt timestamp', () => {
const originalDate = new Date('2024-01-01');
const roadmap = createTestRoadmap({
features: [createTestFeature({ id: 'feature-1', phaseId: 'phase-1' })],
updatedAt: originalDate
});
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().updateFeaturePhase('feature-1', 'phase-2');
const state = useRoadmapStore.getState();
expect(state.roadmap?.updatedAt.getTime()).toBeGreaterThan(originalDate.getTime());
});
it('should do nothing for non-existent feature', () => {
const features = [createTestFeature({ id: 'feature-1', phaseId: 'phase-1' })];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().updateFeaturePhase('nonexistent', 'phase-2');
const state = useRoadmapStore.getState();
expect(state.roadmap?.features).toHaveLength(1);
expect(state.roadmap?.features[0].phaseId).toBe('phase-1');
});
it('should do nothing if roadmap is null', () => {
useRoadmapStore.setState({ roadmap: null });
useRoadmapStore.getState().updateFeaturePhase('feature-1', 'phase-2');
expect(useRoadmapStore.getState().roadmap).toBeNull();
});
it('should handle moving feature to same phase (no change needed)', () => {
const features = [createTestFeature({ id: 'feature-1', phaseId: 'phase-1' })];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().updateFeaturePhase('feature-1', 'phase-1');
const state = useRoadmapStore.getState();
expect(state.roadmap?.features.find((f) => f.id === 'feature-1')?.phaseId).toBe('phase-1');
});
});
describe('addFeature', () => {
it('should add a new feature to the roadmap', () => {
const roadmap = createTestRoadmap({ features: [] });
useRoadmapStore.setState({ roadmap });
const newFeature = {
title: 'New Feature',
description: 'New feature description',
rationale: 'New feature rationale',
priority: 'must' as RoadmapFeaturePriority,
complexity: 'high' as const,
impact: 'high' as const,
phaseId: 'phase-1',
dependencies: [],
status: 'idea' as RoadmapFeatureStatus,
acceptanceCriteria: ['Criteria 1'],
userStories: ['User story 1']
};
const newId = useRoadmapStore.getState().addFeature(newFeature);
const state = useRoadmapStore.getState();
expect(state.roadmap?.features).toHaveLength(1);
expect(state.roadmap?.features[0].id).toBe(newId);
expect(state.roadmap?.features[0].title).toBe('New Feature');
});
it('should generate unique ID for new feature', () => {
const roadmap = createTestRoadmap({ features: [] });
useRoadmapStore.setState({ roadmap });
const featureData = {
title: 'Feature',
description: 'Description',
rationale: 'Rationale',
priority: 'should' as RoadmapFeaturePriority,
complexity: 'medium' as const,
impact: 'medium' as const,
phaseId: 'phase-1',
dependencies: [],
status: 'idea' as RoadmapFeatureStatus,
acceptanceCriteria: [],
userStories: []
};
const id1 = useRoadmapStore.getState().addFeature(featureData);
const id2 = useRoadmapStore.getState().addFeature(featureData);
expect(id1).toBeDefined();
expect(id2).toBeDefined();
expect(id1).not.toBe(id2);
expect(id1).toMatch(/^feature-\d+-[a-z0-9]+$/);
});
it('should append feature to existing features', () => {
const features = [
createTestFeature({ id: 'existing-1' }),
createTestFeature({ id: 'existing-2' })
];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().addFeature({
title: 'New Feature',
description: 'Description',
rationale: 'Rationale',
priority: 'could' as RoadmapFeaturePriority,
complexity: 'low' as const,
impact: 'low' as const,
phaseId: 'phase-2',
dependencies: [],
status: 'planned' as RoadmapFeatureStatus,
acceptanceCriteria: [],
userStories: []
});
const state = useRoadmapStore.getState();
expect(state.roadmap?.features).toHaveLength(3);
expect(state.roadmap?.features[2].title).toBe('New Feature');
});
it('should update updatedAt timestamp', () => {
const originalDate = new Date('2024-01-01');
const roadmap = createTestRoadmap({ features: [], updatedAt: originalDate });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().addFeature({
title: 'New Feature',
description: 'Description',
rationale: 'Rationale',
priority: 'must' as RoadmapFeaturePriority,
complexity: 'medium' as const,
impact: 'high' as const,
phaseId: 'phase-1',
dependencies: [],
status: 'idea' as RoadmapFeatureStatus,
acceptanceCriteria: [],
userStories: []
});
const state = useRoadmapStore.getState();
expect(state.roadmap?.updatedAt.getTime()).toBeGreaterThan(originalDate.getTime());
});
it('should return empty string if roadmap is null', () => {
useRoadmapStore.setState({ roadmap: null });
const newId = useRoadmapStore.getState().addFeature({
title: 'New Feature',
description: 'Description',
rationale: 'Rationale',
priority: 'must' as RoadmapFeaturePriority,
complexity: 'medium' as const,
impact: 'high' as const,
phaseId: 'phase-1',
dependencies: [],
status: 'idea' as RoadmapFeatureStatus,
acceptanceCriteria: [],
userStories: []
});
// The function still generates an ID, but the roadmap remains null
expect(newId).toMatch(/^feature-\d+-[a-z0-9]+$/);
expect(useRoadmapStore.getState().roadmap).toBeNull();
});
it('should correctly assign phaseId from input', () => {
const roadmap = createTestRoadmap({ features: [] });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().addFeature({
title: 'Phase 3 Feature',
description: 'Description',
rationale: 'Rationale',
priority: 'should' as RoadmapFeaturePriority,
complexity: 'medium' as const,
impact: 'medium' as const,
phaseId: 'phase-3',
dependencies: [],
status: 'idea' as RoadmapFeatureStatus,
acceptanceCriteria: [],
userStories: []
});
const state = useRoadmapStore.getState();
expect(state.roadmap?.features[0].phaseId).toBe('phase-3');
});
it('should preserve all feature properties', () => {
const roadmap = createTestRoadmap({ features: [] });
useRoadmapStore.setState({ roadmap });
const featureData = {
title: 'Complete Feature',
description: 'Full description',
rationale: 'Solid rationale',
priority: 'must' as RoadmapFeaturePriority,
complexity: 'high' as const,
impact: 'high' as const,
phaseId: 'phase-1',
dependencies: ['dep-1', 'dep-2'],
status: 'planned' as RoadmapFeatureStatus,
acceptanceCriteria: ['AC1', 'AC2'],
userStories: ['Story 1', 'Story 2'],
linkedSpecId: 'spec-123',
competitorInsightIds: ['insight-1']
};
useRoadmapStore.getState().addFeature(featureData);
const state = useRoadmapStore.getState();
const addedFeature = state.roadmap?.features[0];
expect(addedFeature?.title).toBe('Complete Feature');
expect(addedFeature?.description).toBe('Full description');
expect(addedFeature?.rationale).toBe('Solid rationale');
expect(addedFeature?.priority).toBe('must');
expect(addedFeature?.complexity).toBe('high');
expect(addedFeature?.impact).toBe('high');
expect(addedFeature?.dependencies).toEqual(['dep-1', 'dep-2']);
expect(addedFeature?.acceptanceCriteria).toEqual(['AC1', 'AC2']);
expect(addedFeature?.userStories).toEqual(['Story 1', 'Story 2']);
expect(addedFeature?.linkedSpecId).toBe('spec-123');
expect(addedFeature?.competitorInsightIds).toEqual(['insight-1']);
});
});
describe('updateFeatureStatus', () => {
it('should update feature status by id', () => {
const features = [createTestFeature({ id: 'feature-1', status: 'idea' })];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().updateFeatureStatus('feature-1', 'in_progress');
const state = useRoadmapStore.getState();
expect(state.roadmap?.features[0].status).toBe('in_progress');
});
});
describe('updateFeatureLinkedSpec', () => {
it('should update linked spec and set status to planned', () => {
const features = [createTestFeature({ id: 'feature-1', status: 'idea' })];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().updateFeatureLinkedSpec('feature-1', 'spec-abc');
const state = useRoadmapStore.getState();
expect(state.roadmap?.features[0].linkedSpecId).toBe('spec-abc');
expect(state.roadmap?.features[0].status).toBe('planned');
});
});
describe('clearRoadmap', () => {
it('should clear roadmap and reset status', () => {
useRoadmapStore.setState({
roadmap: createTestRoadmap(),
generationStatus: {
phase: 'complete',
progress: 100,
message: 'Done'
}
});
useRoadmapStore.getState().clearRoadmap();
const state = useRoadmapStore.getState();
expect(state.roadmap).toBeNull();
expect(state.generationStatus.phase).toBe('idle');
expect(state.generationStatus.progress).toBe(0);
});
});
describe('Helper Functions', () => {
describe('getFeaturesByPhase', () => {
it('should return features for specific phase', () => {
const roadmap = createTestRoadmap({
features: [
createTestFeature({ id: 'f1', phaseId: 'phase-1' }),
createTestFeature({ id: 'f2', phaseId: 'phase-1' }),
createTestFeature({ id: 'f3', phaseId: 'phase-2' })
]
});
const phase1Features = getFeaturesByPhase(roadmap, 'phase-1');
expect(phase1Features).toHaveLength(2);
expect(phase1Features.map((f) => f.id)).toContain('f1');
expect(phase1Features.map((f) => f.id)).toContain('f2');
});
it('should return empty array for null roadmap', () => {
const features = getFeaturesByPhase(null, 'phase-1');
expect(features).toHaveLength(0);
});
it('should return empty array for non-existent phase', () => {
const roadmap = createTestRoadmap({
features: [createTestFeature({ id: 'f1', phaseId: 'phase-1' })]
});
const features = getFeaturesByPhase(roadmap, 'non-existent');
expect(features).toHaveLength(0);
});
});
describe('getFeaturesByPriority', () => {
it('should return features for specific priority', () => {
const roadmap = createTestRoadmap({
features: [
createTestFeature({ id: 'f1', priority: 'must' }),
createTestFeature({ id: 'f2', priority: 'should' }),
createTestFeature({ id: 'f3', priority: 'must' })
]
});
const mustFeatures = getFeaturesByPriority(roadmap, 'must');
expect(mustFeatures).toHaveLength(2);
expect(mustFeatures.map((f) => f.id)).toContain('f1');
expect(mustFeatures.map((f) => f.id)).toContain('f3');
});
it('should return empty array for null roadmap', () => {
const features = getFeaturesByPriority(null, 'must');
expect(features).toHaveLength(0);
});
});
describe('getFeatureStats', () => {
it('should return correct stats', () => {
const roadmap = createTestRoadmap({
features: [
createTestFeature({ priority: 'must', status: 'idea', complexity: 'high' }),
createTestFeature({ priority: 'must', status: 'planned', complexity: 'medium' }),
createTestFeature({ priority: 'should', status: 'idea', complexity: 'low' })
]
});
const stats = getFeatureStats(roadmap);
expect(stats.total).toBe(3);
expect(stats.byPriority['must']).toBe(2);
expect(stats.byPriority['should']).toBe(1);
expect(stats.byStatus['idea']).toBe(2);
expect(stats.byStatus['planned']).toBe(1);
expect(stats.byComplexity['high']).toBe(1);
expect(stats.byComplexity['medium']).toBe(1);
expect(stats.byComplexity['low']).toBe(1);
});
it('should return zero stats for null roadmap', () => {
const stats = getFeatureStats(null);
expect(stats.total).toBe(0);
expect(stats.byPriority).toEqual({});
expect(stats.byStatus).toEqual({});
expect(stats.byComplexity).toEqual({});
});
it('should return zero stats for empty features', () => {
const roadmap = createTestRoadmap({ features: [] });
const stats = getFeatureStats(roadmap);
expect(stats.total).toBe(0);
});
});
});
});
@@ -0,0 +1,367 @@
/**
* AddFeatureDialog - Dialog for adding new features to the roadmap
*
* Allows users to create new roadmap features with title, description,
* priority, phase, complexity, and impact fields.
* Follows the same dialog pattern as TaskEditDialog for consistency.
*
* Features:
* - Form validation (title and description required)
* - Selectable classification fields (priority, phase, complexity, impact)
* - Adds feature to roadmap store and persists to file
*
* @example
* ```tsx
* <AddFeatureDialog
* phases={roadmap.phases}
* open={isAddDialogOpen}
* onOpenChange={setIsAddDialogOpen}
* onFeatureAdded={(featureId) => console.log('Feature added:', featureId)}
* />
* ```
*/
import { useState, useEffect } from 'react';
import { Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Textarea } from './ui/textarea';
import { Label } from './ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from './ui/select';
import { useRoadmapStore } from '../stores/roadmap-store';
import {
ROADMAP_PRIORITY_LABELS,
ROADMAP_COMPLEXITY_COLORS,
ROADMAP_IMPACT_COLORS
} from '../../shared/constants';
import type {
RoadmapPhase,
RoadmapFeaturePriority,
RoadmapFeatureStatus
} from '../../shared/types';
/**
* Props for the AddFeatureDialog component
*/
interface AddFeatureDialogProps {
/** Available phases to select from */
phases: RoadmapPhase[];
/** Whether the dialog is open */
open: boolean;
/** Callback when the dialog open state changes */
onOpenChange: (open: boolean) => void;
/** Optional callback when feature is successfully added, receives the new feature ID */
onFeatureAdded?: (featureId: string) => void;
/** Optional default phase ID to pre-select */
defaultPhaseId?: string;
}
// Complexity options
const COMPLEXITY_OPTIONS = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' }
] as const;
// Impact options
const IMPACT_OPTIONS = [
{ value: 'low', label: 'Low Impact' },
{ value: 'medium', label: 'Medium Impact' },
{ value: 'high', label: 'High Impact' }
] as const;
export function AddFeatureDialog({
phases,
open,
onOpenChange,
onFeatureAdded,
defaultPhaseId
}: AddFeatureDialogProps) {
// Form state
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [rationale, setRationale] = useState('');
const [priority, setPriority] = useState<RoadmapFeaturePriority>('should');
const [phaseId, setPhaseId] = useState<string>('');
const [complexity, setComplexity] = useState<'low' | 'medium' | 'high'>('medium');
const [impact, setImpact] = useState<'low' | 'medium' | 'high'>('medium');
// UI state
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Store actions
const addFeature = useRoadmapStore((state) => state.addFeature);
// Reset form when dialog opens/closes
useEffect(() => {
if (open) {
setTitle('');
setDescription('');
setRationale('');
setPriority('should');
setPhaseId(defaultPhaseId || (phases.length > 0 ? phases[0].id : ''));
setComplexity('medium');
setImpact('medium');
setError(null);
}
}, [open, defaultPhaseId, phases]);
const handleSave = async () => {
// Validate required fields
if (!title.trim()) {
setError('Title is required');
return;
}
if (!description.trim()) {
setError('Description is required');
return;
}
if (!phaseId) {
setError('Please select a phase');
return;
}
setIsSaving(true);
setError(null);
try {
// Add feature to store
const newFeatureId = addFeature({
title: title.trim(),
description: description.trim(),
rationale: rationale.trim() || `User-created feature for ${title.trim()}`,
priority,
complexity,
impact,
phaseId,
dependencies: [],
status: 'idea' as RoadmapFeatureStatus,
acceptanceCriteria: [],
userStories: []
});
// Persist to file via IPC
const roadmap = useRoadmapStore.getState().roadmap;
if (roadmap) {
// Get the project ID from the roadmap
const result = await window.electronAPI.saveRoadmap(roadmap.projectId, roadmap);
if (!result.success) {
throw new Error(result.error || 'Failed to save roadmap');
}
}
// Success - close dialog and notify parent
onOpenChange(false);
onFeatureAdded?.(newFeatureId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add feature. Please try again.');
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
if (!isSaving) {
onOpenChange(false);
}
};
// Form validation
const isValid = title.trim().length > 0 && description.trim().length > 0 && phaseId !== '';
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[550px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-foreground">Add Feature</DialogTitle>
<DialogDescription>
Add a new feature to your roadmap. Provide details about what you want to build
and how it fits into your product strategy.
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-4">
{/* Title (Required) */}
<div className="space-y-2">
<Label htmlFor="add-feature-title" className="text-sm font-medium text-foreground">
Feature Title <span className="text-destructive">*</span>
</Label>
<Input
id="add-feature-title"
placeholder="e.g., User Authentication, Dark Mode Support"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isSaving}
/>
</div>
{/* Description (Required) */}
<div className="space-y-2">
<Label htmlFor="add-feature-description" className="text-sm font-medium text-foreground">
Description <span className="text-destructive">*</span>
</Label>
<Textarea
id="add-feature-description"
placeholder="Describe what this feature does and why it's valuable to users."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
disabled={isSaving}
/>
</div>
{/* Rationale (Optional) */}
<div className="space-y-2">
<Label htmlFor="add-feature-rationale" className="text-sm font-medium text-foreground">
Rationale <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Textarea
id="add-feature-rationale"
placeholder="Explain why this feature should be built and how it fits the product vision."
value={rationale}
onChange={(e) => setRationale(e.target.value)}
rows={2}
disabled={isSaving}
/>
</div>
{/* Classification Fields */}
<div className="grid grid-cols-2 gap-4">
{/* Phase */}
<div className="space-y-2">
<Label htmlFor="add-feature-phase" className="text-sm font-medium text-foreground">
Phase <span className="text-destructive">*</span>
</Label>
<Select
value={phaseId}
onValueChange={setPhaseId}
disabled={isSaving}
>
<SelectTrigger id="add-feature-phase">
<SelectValue placeholder="Select phase" />
</SelectTrigger>
<SelectContent>
{phases.map((phase) => (
<SelectItem key={phase.id} value={phase.id}>
{phase.order}. {phase.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Priority */}
<div className="space-y-2">
<Label htmlFor="add-feature-priority" className="text-sm font-medium text-foreground">
Priority
</Label>
<Select
value={priority}
onValueChange={(value) => setPriority(value as RoadmapFeaturePriority)}
disabled={isSaving}
>
<SelectTrigger id="add-feature-priority">
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{Object.entries(ROADMAP_PRIORITY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Complexity */}
<div className="space-y-2">
<Label htmlFor="add-feature-complexity" className="text-sm font-medium text-foreground">
Complexity
</Label>
<Select
value={complexity}
onValueChange={(value) => setComplexity(value as 'low' | 'medium' | 'high')}
disabled={isSaving}
>
<SelectTrigger id="add-feature-complexity">
<SelectValue placeholder="Select complexity" />
</SelectTrigger>
<SelectContent>
{COMPLEXITY_OPTIONS.map(({ value, label }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Impact */}
<div className="space-y-2">
<Label htmlFor="add-feature-impact" className="text-sm font-medium text-foreground">
Impact
</Label>
<Select
value={impact}
onValueChange={(value) => setImpact(value as 'low' | 'medium' | 'high')}
disabled={isSaving}
>
<SelectTrigger id="add-feature-impact">
<SelectValue placeholder="Select impact" />
</SelectTrigger>
<SelectContent>
{IMPACT_OPTIONS.map(({ value, label }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
<X className="h-4 w-4 mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !isValid}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Adding...
</>
) : (
'Add Feature'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -16,7 +16,9 @@ import {
AlertCircle,
Play,
ExternalLink,
TrendingUp} from 'lucide-react';
TrendingUp,
Plus
} from 'lucide-react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Card } from './ui/card';
@@ -42,6 +44,8 @@ import {
ROADMAP_IMPACT_COLORS
} from '../../shared/constants';
import { CompetitorAnalysisDialog } from './CompetitorAnalysisDialog';
import { RoadmapKanbanView } from './RoadmapKanbanView';
import { AddFeatureDialog } from './AddFeatureDialog';
import type { RoadmapFeature, RoadmapPhase, CompetitorAnalysis, CompetitorPainPoint } from '../../shared/types';
interface RoadmapProps {
@@ -57,6 +61,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
const [selectedFeature, setSelectedFeature] = useState<RoadmapFeature | null>(null);
const [activeTab, setActiveTab] = useState('phases');
const [showCompetitorDialog, setShowCompetitorDialog] = useState(false);
const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false);
const [pendingAction, setPendingAction] = useState<'generate' | 'refresh' | null>(null);
// Load roadmap on mount
@@ -76,18 +81,18 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
const handleCompetitorDialogAccept = () => {
if (pendingAction === 'generate') {
generateRoadmap(projectId, true);
generateRoadmap(projectId);
} else if (pendingAction === 'refresh') {
refreshRoadmap(projectId, true);
refreshRoadmap(projectId);
}
setPendingAction(null);
};
const handleCompetitorDialogDecline = () => {
if (pendingAction === 'generate') {
generateRoadmap(projectId, false);
generateRoadmap(projectId);
} else if (pendingAction === 'refresh') {
refreshRoadmap(projectId, false);
refreshRoadmap(projectId);
}
setPendingAction(null);
};
@@ -196,6 +201,15 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
<p className="text-sm text-muted-foreground max-w-xl">{roadmap.vision}</p>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" onClick={() => setShowAddFeatureDialog(true)}>
<Plus className="h-4 w-4 mr-1" />
Add Feature
</Button>
</TooltipTrigger>
<TooltipContent>Add a new feature to the roadmap</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={handleRefresh}>
@@ -257,6 +271,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
<TabsTrigger value="phases">Phases</TabsTrigger>
<TabsTrigger value="features">All Features</TabsTrigger>
<TabsTrigger value="priorities">By Priority</TabsTrigger>
<TabsTrigger value="kanban">Kanban</TabsTrigger>
</TabsList>
{/* Phases View */}
@@ -341,6 +356,16 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
})}
</div>
</TabsContent>
{/* Kanban View */}
<TabsContent value="kanban" className="flex-1 overflow-hidden">
<RoadmapKanbanView
roadmap={roadmap}
onFeatureClick={setSelectedFeature}
onConvertToSpec={handleConvertToSpec}
onGoToTask={handleGoToTask}
/>
</TabsContent>
</Tabs>
</div>
@@ -361,6 +386,13 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
onAccept={handleCompetitorDialogAccept}
onDecline={handleCompetitorDialogDecline}
/>
{/* Add Feature Dialog */}
<AddFeatureDialog
phases={roadmap.phases}
open={showAddFeatureDialog}
onOpenChange={setShowAddFeatureDialog}
/>
</div>
);
}
@@ -0,0 +1,345 @@
import { useState, useMemo } from 'react';
import {
DndContext,
DragOverlay,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
useDroppable,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
arrayMove
} from '@dnd-kit/sortable';
import { Plus, Inbox } from 'lucide-react';
import { ScrollArea } from './ui/scroll-area';
import { Badge } from './ui/badge';
import { Card } from './ui/card';
import { SortableFeatureCard } from './SortableFeatureCard';
import { cn } from '../lib/utils';
import {
useRoadmapStore,
getFeaturesByPhase
} from '../stores/roadmap-store';
import type { RoadmapFeature, RoadmapPhase, Roadmap } from '../../shared/types';
interface RoadmapKanbanViewProps {
roadmap: Roadmap;
onFeatureClick: (feature: RoadmapFeature) => void;
onConvertToSpec?: (feature: RoadmapFeature) => void;
onGoToTask?: (specId: string) => void;
onSave?: () => void;
}
interface DroppablePhaseColumnProps {
phase: RoadmapPhase;
features: RoadmapFeature[];
onFeatureClick: (feature: RoadmapFeature) => void;
onConvertToSpec?: (feature: RoadmapFeature) => void;
onGoToTask?: (specId: string) => void;
isOver: boolean;
}
// Get phase status color for column header
function getPhaseStatusColor(status: string): string {
switch (status) {
case 'completed':
return 'border-t-success';
case 'in_progress':
return 'border-t-primary';
default:
return 'border-t-muted-foreground/30';
}
}
function DroppablePhaseColumn({
phase,
features,
onFeatureClick,
onConvertToSpec,
onGoToTask,
isOver
}: DroppablePhaseColumnProps) {
const { setNodeRef } = useDroppable({
id: phase.id
});
const featureIds = features.map((f) => f.id);
return (
<div
ref={setNodeRef}
className={cn(
'flex w-72 shrink-0 flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
getPhaseStatusColor(phase.status),
'border-t-2',
isOver && 'drop-zone-highlight'
)}
>
{/* Column header */}
<div className="flex items-center justify-between p-4 border-b border-white/5">
<div className="flex items-center gap-2.5">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold ${
phase.status === 'completed'
? 'bg-success/10 text-success'
: phase.status === 'in_progress'
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{phase.order}
</div>
<h2 className="font-semibold text-sm text-foreground truncate max-w-[140px]">
{phase.name}
</h2>
<span className="column-count-badge">
{features.length}
</span>
</div>
<Badge
variant={phase.status === 'completed' ? 'default' : 'outline'}
className="text-xs"
>
{phase.status.replace('_', ' ')}
</Badge>
</div>
{/* Features list */}
<div className="flex-1 min-h-0">
<ScrollArea className="h-full px-3 pb-3 pt-2">
<SortableContext
items={featureIds}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3 min-h-[120px]">
{features.length === 0 ? (
<div
className={cn(
'empty-column-dropzone flex flex-col items-center justify-center py-6',
isOver && 'active'
)}
>
{isOver ? (
<>
<div className="h-8 w-8 rounded-full bg-primary/20 flex items-center justify-center mb-2">
<Plus className="h-4 w-4 text-primary" />
</div>
<span className="text-sm font-medium text-primary">Drop here</span>
</>
) : (
<>
<Inbox className="h-6 w-6 text-muted-foreground/50" />
<span className="mt-2 text-sm font-medium text-muted-foreground/70">
No features
</span>
<span className="mt-0.5 text-xs text-muted-foreground/50">
Drag features here
</span>
</>
)}
</div>
) : (
features.map((feature) => (
<SortableFeatureCard
key={feature.id}
feature={feature}
onClick={() => onFeatureClick(feature)}
onConvertToSpec={onConvertToSpec}
onGoToTask={onGoToTask}
/>
))
)}
</div>
</SortableContext>
</ScrollArea>
</div>
</div>
);
}
export function RoadmapKanbanView({
roadmap,
onFeatureClick,
onConvertToSpec,
onGoToTask,
onSave
}: RoadmapKanbanViewProps) {
const [activeFeature, setActiveFeature] = useState<RoadmapFeature | null>(null);
const [overColumnId, setOverColumnId] = useState<string | null>(null);
const reorderFeatures = useRoadmapStore((state) => state.reorderFeatures);
const updateFeaturePhase = useRoadmapStore((state) => state.updateFeaturePhase);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8 // 8px movement required before drag starts
}
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
);
// Get features grouped by phase
const featuresByPhase = useMemo(() => {
const grouped: Record<string, RoadmapFeature[]> = {};
roadmap.phases.forEach((phase) => {
grouped[phase.id] = getFeaturesByPhase(roadmap, phase.id);
});
return grouped;
}, [roadmap]);
// Get all phase IDs for detecting column drops
const phaseIds = useMemo(() => roadmap.phases.map((p) => p.id), [roadmap.phases]);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const feature = roadmap.features.find((f) => f.id === active.id);
if (feature) {
setActiveFeature(feature);
}
};
const handleDragOver = (event: DragOverEvent) => {
const { over } = event;
if (!over) {
setOverColumnId(null);
return;
}
const overId = over.id as string;
// Check if over a phase column
if (phaseIds.includes(overId)) {
setOverColumnId(overId);
return;
}
// Check if over a feature - get its phase
const overFeature = roadmap.features.find((f) => f.id === overId);
if (overFeature) {
setOverColumnId(overFeature.phaseId);
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveFeature(null);
setOverColumnId(null);
if (!over) return;
const activeFeatureId = active.id as string;
const overId = over.id as string;
const draggedFeature = roadmap.features.find((f) => f.id === activeFeatureId);
if (!draggedFeature) return;
// Determine target phase
let targetPhaseId: string;
let targetFeatureIndex: number = -1;
if (phaseIds.includes(overId)) {
// Dropped directly on a phase column
targetPhaseId = overId;
} else {
// Dropped on a feature - get its phase and position
const overFeature = roadmap.features.find((f) => f.id === overId);
if (!overFeature) return;
targetPhaseId = overFeature.phaseId;
const targetFeatures = featuresByPhase[targetPhaseId] || [];
targetFeatureIndex = targetFeatures.findIndex((f) => f.id === overId);
}
const sourcePhaseId = draggedFeature.phaseId;
if (sourcePhaseId !== targetPhaseId) {
// Moving to a different phase
updateFeaturePhase(activeFeatureId, targetPhaseId);
// If dropped on a specific feature, reorder within the new phase
if (targetFeatureIndex !== -1) {
const targetFeatures = [...(featuresByPhase[targetPhaseId] || [])];
// Add the moved feature at the target position
const updatedIds = targetFeatures.map((f) => f.id);
if (!updatedIds.includes(activeFeatureId)) {
updatedIds.splice(targetFeatureIndex, 0, activeFeatureId);
reorderFeatures(targetPhaseId, updatedIds);
}
}
// Trigger save callback
onSave?.();
} else {
// Reordering within the same phase
const sourceFeatures = featuresByPhase[sourcePhaseId] || [];
const oldIndex = sourceFeatures.findIndex((f) => f.id === activeFeatureId);
const newIndex = targetFeatureIndex !== -1 ? targetFeatureIndex : sourceFeatures.length - 1;
if (oldIndex !== newIndex) {
const reorderedIds = arrayMove(
sourceFeatures.map((f) => f.id),
oldIndex,
newIndex
);
reorderFeatures(sourcePhaseId, reorderedIds);
// Trigger save callback
onSave?.();
}
}
};
return (
<div className="flex h-full flex-col">
{/* Kanban columns */}
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex flex-1 gap-4 overflow-x-auto p-6">
{roadmap.phases
.sort((a, b) => a.order - b.order)
.map((phase) => (
<DroppablePhaseColumn
key={phase.id}
phase={phase}
features={featuresByPhase[phase.id] || []}
onFeatureClick={onFeatureClick}
onConvertToSpec={onConvertToSpec}
onGoToTask={onGoToTask}
isOver={overColumnId === phase.id}
/>
))}
</div>
{/* Drag overlay - enhanced visual feedback */}
<DragOverlay>
{activeFeature ? (
<div className="drag-overlay-card">
<Card className="p-4 w-72 shadow-2xl">
<div className="font-medium">{activeFeature.title}</div>
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{activeFeature.description}
</p>
</Card>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
);
}
@@ -0,0 +1,145 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '../lib/utils';
import { Card } from './ui/card';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger
} from './ui/tooltip';
import { Play, ExternalLink, TrendingUp } from 'lucide-react';
import {
ROADMAP_PRIORITY_COLORS,
ROADMAP_PRIORITY_LABELS,
ROADMAP_COMPLEXITY_COLORS,
ROADMAP_IMPACT_COLORS
} from '../../shared/constants';
import type { RoadmapFeature } from '../../shared/types';
interface SortableFeatureCardProps {
feature: RoadmapFeature;
onClick: () => void;
onConvertToSpec?: (feature: RoadmapFeature) => void;
onGoToTask?: (specId: string) => void;
}
export function SortableFeatureCard({
feature,
onClick,
onConvertToSpec,
onGoToTask
}: SortableFeatureCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
isOver
} = useSortable({ id: feature.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
// Prevent z-index stacking issues during drag
zIndex: isDragging ? 50 : undefined
};
const hasCompetitorInsight =
!!feature.competitorInsightIds && feature.competitorInsightIds.length > 0;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'touch-none transition-all duration-200',
isDragging && 'dragging-placeholder opacity-40 scale-[0.98]',
isOver && !isDragging && 'ring-2 ring-primary/30 ring-offset-2 ring-offset-background rounded-xl'
)}
{...attributes}
{...listeners}
>
<Card
className="p-4 hover:bg-muted/50 cursor-pointer transition-colors"
onClick={onClick}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<Badge
variant="outline"
className={ROADMAP_PRIORITY_COLORS[feature.priority]}
>
{ROADMAP_PRIORITY_LABELS[feature.priority]}
</Badge>
<Badge
variant="outline"
className={`text-xs ${ROADMAP_COMPLEXITY_COLORS[feature.complexity]}`}
>
{feature.complexity}
</Badge>
<Badge
variant="outline"
className={`text-xs ${ROADMAP_IMPACT_COLORS[feature.impact]}`}
>
{feature.impact} impact
</Badge>
{hasCompetitorInsight && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-xs text-primary border-primary/50"
>
<TrendingUp className="h-3 w-3 mr-1" />
Competitor Insight
</Badge>
</TooltipTrigger>
<TooltipContent>
This feature addresses competitor pain points
</TooltipContent>
</Tooltip>
)}
</div>
<h3 className="font-medium truncate">{feature.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2">
{feature.description}
</p>
</div>
{feature.linkedSpecId ? (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onGoToTask?.(feature.linkedSpecId!);
}}
>
<ExternalLink className="h-3 w-3 mr-1" />
Go to Task
</Button>
) : (
feature.status !== 'done' &&
onConvertToSpec && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onConvertToSpec(feature);
}}
>
<Play className="h-3 w-3 mr-1" />
Build
</Button>
)
)}
</div>
</Card>
</div>
);
}
@@ -49,8 +49,45 @@ const browserMockAPI: ElectronAPI = {
...settingsMock,
// Roadmap Operations
...roadmapMock,
getRoadmap: async () => ({
success: true,
data: null
}),
saveRoadmap: async () => ({
success: true
}),
generateRoadmap: () => {
console.log('[Browser Mock] generateRoadmap called');
},
refreshRoadmap: () => {
console.log('[Browser Mock] refreshRoadmap called');
},
updateFeatureStatus: async () => ({ success: true }),
convertFeatureToSpec: async (projectId: string, featureId: string) => ({
success: true,
data: {
id: `task-${Date.now()}`,
specId: '',
projectId,
title: 'Converted Feature',
description: 'Feature converted from roadmap',
status: 'backlog' as const,
subtasks: [],
logs: [],
createdAt: new Date(),
updatedAt: new Date()
}
}),
// Roadmap Event Listeners
onRoadmapProgress: () => () => {},
onRoadmapComplete: () => () => {},
onRoadmapError: () => () => {},
// Context Operations
...contextMock,
@@ -20,6 +20,10 @@ interface RoadmapState {
updateFeatureStatus: (featureId: string, status: RoadmapFeatureStatus) => void;
updateFeatureLinkedSpec: (featureId: string, specId: string) => void;
clearRoadmap: () => void;
// Drag-and-drop actions
reorderFeatures: (phaseId: string, featureIds: string[]) => void;
updateFeaturePhase: (featureId: string, newPhaseId: string) => void;
addFeature: (feature: Omit<RoadmapFeature, 'id'>) => string;
}
const initialGenerationStatus: RoadmapGenerationStatus = {
@@ -82,7 +86,75 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
roadmap: null,
competitorAnalysis: null,
generationStatus: initialGenerationStatus
})
}),
// Reorder features within a phase
reorderFeatures: (phaseId, featureIds) =>
set((state) => {
if (!state.roadmap) return state;
// Get features for this phase in the new order
const phaseFeatures = featureIds
.map((id) => state.roadmap!.features.find((f) => f.id === id))
.filter((f): f is RoadmapFeature => f !== undefined);
// Get features from other phases (unchanged)
const otherFeatures = state.roadmap.features.filter(
(f) => f.phaseId !== phaseId
);
// Combine: other phases first, then reordered phase features
const updatedFeatures = [...otherFeatures, ...phaseFeatures];
return {
roadmap: {
...state.roadmap,
features: updatedFeatures,
updatedAt: new Date()
}
};
}),
// Move a feature to a different phase
updateFeaturePhase: (featureId, newPhaseId) =>
set((state) => {
if (!state.roadmap) return state;
const updatedFeatures = state.roadmap.features.map((feature) =>
feature.id === featureId ? { ...feature, phaseId: newPhaseId } : feature
);
return {
roadmap: {
...state.roadmap,
features: updatedFeatures,
updatedAt: new Date()
}
};
}),
// Add a new feature to the roadmap
addFeature: (featureData) => {
const newId = `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newFeature: RoadmapFeature = {
...featureData,
id: newId
};
set((state) => {
if (!state.roadmap) return state;
return {
roadmap: {
...state.roadmap,
features: [...state.roadmap.features, newFeature],
updatedAt: new Date()
}
};
});
return newId;
}
}));
// Helper functions for loading roadmap
@@ -104,32 +176,22 @@ export async function loadRoadmap(projectId: string): Promise<void> {
}
}
export function generateRoadmap(
projectId: string,
enableCompetitorAnalysis: boolean = false
): void {
export function generateRoadmap(projectId: string): void {
useRoadmapStore.getState().setGenerationStatus({
phase: 'analyzing',
progress: 0,
message: enableCompetitorAnalysis
? 'Starting roadmap generation with competitor analysis...'
: 'Starting roadmap generation...'
message: 'Starting roadmap generation...'
});
window.electronAPI.generateRoadmap(projectId, enableCompetitorAnalysis);
window.electronAPI.generateRoadmap(projectId);
}
export function refreshRoadmap(
projectId: string,
enableCompetitorAnalysis: boolean = false
): void {
export function refreshRoadmap(projectId: string): void {
useRoadmapStore.getState().setGenerationStatus({
phase: 'analyzing',
progress: 0,
message: enableCompetitorAnalysis
? 'Refreshing roadmap with competitor analysis...'
: 'Refreshing roadmap...'
message: 'Refreshing roadmap...'
});
window.electronAPI.refreshRoadmap(projectId, enableCompetitorAnalysis);
window.electronAPI.refreshRoadmap(projectId);
}
// Selectors
+1
View File
@@ -227,6 +227,7 @@ export const IPC_CHANNELS = {
// Roadmap operations
ROADMAP_GET: 'roadmap:get',
ROADMAP_SAVE: 'roadmap:save',
ROADMAP_GENERATE: 'roadmap:generate',
ROADMAP_GENERATE_WITH_COMPETITOR: 'roadmap:generateWithCompetitor',
ROADMAP_REFRESH: 'roadmap:refresh',
+1
View File
@@ -220,6 +220,7 @@ export interface ElectronAPI {
// Roadmap operations
getRoadmap: (projectId: string) => Promise<IPCResult<Roadmap | null>>;
saveRoadmap: (projectId: string, roadmap: Roadmap) => Promise<IPCResult>;
generateRoadmap: (projectId: string) => void;
refreshRoadmap: (projectId: string) => void;
updateFeatureStatus: (