Implement roadmap feature management kanban with drag-and-drop support
This commit is contained in:
Generated
+1213
-2
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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: (
|
||||
|
||||
Reference in New Issue
Block a user