diff --git a/apps/backend/runners/roadmap/competitor_analyzer.py b/apps/backend/runners/roadmap/competitor_analyzer.py index c111d106..6ea4bddf 100644 --- a/apps/backend/runners/roadmap/competitor_analyzer.py +++ b/apps/backend/runners/roadmap/competitor_analyzer.py @@ -31,6 +31,7 @@ class CompetitorAnalyzer: self.refresh = refresh self.agent_executor = agent_executor self.analysis_file = output_dir / "competitor_analysis.json" + self.manual_competitors_file = output_dir / "manual_competitors.json" self.discovery_file = output_dir / "roadmap_discovery.json" self.project_index_file = output_dir / "project_index.json" @@ -42,7 +43,10 @@ class CompetitorAnalyzer: """ if not enabled: print_status("Competitor analysis not enabled, skipping", "info") + manual_competitors = self._get_manual_competitors() self._create_disabled_analysis_file() + if manual_competitors: + self._merge_manual_competitors(manual_competitors) return RoadmapPhaseResult( "competitor_analysis", True, [str(self.analysis_file)], [], 0 ) @@ -53,6 +57,9 @@ class CompetitorAnalyzer: "competitor_analysis", True, [str(self.analysis_file)], [], 0 ) + # Preserve manual competitors before any path that overwrites the file + manual_competitors = self._get_manual_competitors() + if not self.discovery_file.exists(): print_status( "Discovery file not found, skipping competitor analysis", "warning" @@ -60,6 +67,8 @@ class CompetitorAnalyzer: self._create_error_analysis_file( "Discovery file not found - cannot analyze competitors without project context" ) + if manual_competitors: + self._merge_manual_competitors(manual_competitors) return RoadmapPhaseResult( "competitor_analysis", True, @@ -84,6 +93,8 @@ class CompetitorAnalyzer: if success and self.analysis_file.exists(): validation_result = self._validate_analysis() if validation_result is not None: + if manual_competitors: + self._merge_manual_competitors(manual_competitors) return validation_result errors.append(f"Attempt {attempt + 1}: Validation failed") else: @@ -100,12 +111,82 @@ class CompetitorAnalyzer: print(f" {muted('Error:')} {err}") self._create_error_analysis_file("Analysis failed after retries", errors) + if manual_competitors: + self._merge_manual_competitors(manual_competitors) # Return success=True for graceful degradation (don't block roadmap generation) return RoadmapPhaseResult( "competitor_analysis", True, [str(self.analysis_file)], errors, MAX_RETRIES ) + def _get_manual_competitors(self) -> list[dict]: + """Extract manually-added competitors from the dedicated manual file and analysis file. + + Reads from manual_competitors.json (primary, never overwritten by agent) and + falls back to competitor_analysis.json. Deduplicates by competitor ID. + Returns a list of competitor dicts where source == 'manual'. + """ + competitors_by_id: dict[str, dict] = {} + + # Primary source: dedicated manual competitors file (never overwritten by agent) + if self.manual_competitors_file.exists(): + try: + with open(self.manual_competitors_file, encoding="utf-8") as f: + data = json.load(f) + for c in data.get("competitors", []): + if isinstance(c, dict) and c.get("id"): + competitors_by_id[c["id"]] = c + except (json.JSONDecodeError, OSError) as e: + print_status( + f"Warning: could not read manual competitors file: {e}", "warning" + ) + + # Fallback: also check analysis file for manual competitors + if self.analysis_file.exists(): + try: + with open(self.analysis_file, encoding="utf-8") as f: + data = json.load(f) + for c in data.get("competitors", []): + if ( + isinstance(c, dict) + and c.get("source") == "manual" + and c.get("id") + and c["id"] not in competitors_by_id + ): + competitors_by_id[c["id"]] = c + except (json.JSONDecodeError, OSError) as e: + print_status( + f"Warning: could not read manual competitors from analysis: {e}", + "warning", + ) + + return list(competitors_by_id.values()) + + def _merge_manual_competitors(self, manual_competitors: list[dict]) -> None: + """Merge manual competitors back into the newly-generated analysis file. + + Appends manual competitors that don't already exist (by ID) in the file. + """ + if not manual_competitors: + return + + try: + with open(self.analysis_file, encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + print_status(f"Warning: failed to merge manual competitors: {e}", "warning") + return + + existing_ids = { + c.get("id") for c in data.get("competitors", []) if isinstance(c, dict) + } + + for competitor in manual_competitors: + if competitor.get("id") not in existing_ids: + data.setdefault("competitors", []).append(competitor) + + write_json_atomic(self.analysis_file, data, indent=2) + def _build_context(self) -> str: """Build context string for the competitor analysis agent.""" return f""" @@ -140,8 +221,11 @@ Output your findings to competitor_analysis.json. "competitor_analysis", True, [str(self.analysis_file)], [], 0 ) - except json.JSONDecodeError: - pass + except json.JSONDecodeError as e: + print_status( + f"Warning: competitor analysis file is not valid JSON: {e}", + "warning", + ) return None diff --git a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts index 28e576bf..5ee26ec6 100644 --- a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts @@ -127,6 +127,7 @@ export function registerRoadmapHandlers( })), strengths: (c.strengths as string[]) || [], marketPosition: (c.market_position as string) || "", + source: c.source || undefined, })), marketGaps: (rawCompetitor.market_gaps || []).map((g: Record) => ({ id: g.id, @@ -792,6 +793,148 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" } ); + // ============================================ + // Competitor Analysis Save + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.COMPETITOR_ANALYSIS_SAVE, + async ( + _, + projectId: string, + competitorAnalysis: CompetitorAnalysis + ): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: "Project not found" }; + } + + const roadmapDir = path.join(project.path, AUTO_BUILD_PATHS.ROADMAP_DIR); + const competitorAnalysisPath = path.join( + roadmapDir, + AUTO_BUILD_PATHS.COMPETITOR_ANALYSIS + ); + + try { + // Ensure roadmap directory exists + if (!existsSync(roadmapDir)) { + mkdirSync(roadmapDir, { recursive: true }); + } + + await withFileLock(competitorAnalysisPath, async () => { + // Transform camelCase to snake_case for JSON file + const serialized = { + project_context: { + project_name: competitorAnalysis.projectContext.projectName, + project_type: competitorAnalysis.projectContext.projectType, + target_audience: competitorAnalysis.projectContext.targetAudience, + }, + competitors: competitorAnalysis.competitors.map((c) => ({ + id: c.id, + name: c.name, + url: c.url, + description: c.description, + relevance: c.relevance, + pain_points: c.painPoints.map((p) => ({ + id: p.id, + description: p.description, + source: p.source, + severity: p.severity, + frequency: p.frequency, + opportunity: p.opportunity, + })), + strengths: c.strengths, + market_position: c.marketPosition, + source: c.source, + })), + market_gaps: competitorAnalysis.marketGaps.map((g) => ({ + id: g.id, + description: g.description, + affected_competitors: g.affectedCompetitors, + opportunity_size: g.opportunitySize, + suggested_feature: g.suggestedFeature, + })), + insights_summary: { + top_pain_points: competitorAnalysis.insightsSummary.topPainPoints, + differentiator_opportunities: + competitorAnalysis.insightsSummary.differentiatorOpportunities, + market_trends: competitorAnalysis.insightsSummary.marketTrends, + }, + research_metadata: { + search_queries_used: + competitorAnalysis.researchMetadata.searchQueriesUsed, + sources_consulted: + competitorAnalysis.researchMetadata.sourcesConsulted, + limitations: competitorAnalysis.researchMetadata.limitations, + }, + metadata: { + created_at: competitorAnalysis.createdAt + ? new Date(competitorAnalysis.createdAt).toISOString() + : new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + }; + + await writeFileWithRetry( + competitorAnalysisPath, + JSON.stringify(serialized, null, 2), + { encoding: 'utf-8' } + ); + }); + + // Also persist manual competitors to a separate file that the backend + // agent never overwrites, preventing data loss during concurrent analysis + const manualCompetitors = competitorAnalysis.competitors.filter( + (c) => c.source === "manual" + ); + if (manualCompetitors.length > 0) { + const manualCompetitorsPath = path.join( + roadmapDir, + AUTO_BUILD_PATHS.MANUAL_COMPETITORS + ); + const manualSerialized = { + competitors: manualCompetitors.map((c) => ({ + id: c.id, + name: c.name, + url: c.url, + description: c.description, + relevance: c.relevance, + pain_points: c.painPoints.map((p) => ({ + id: p.id, + description: p.description, + source: p.source, + severity: p.severity, + frequency: p.frequency, + opportunity: p.opportunity, + })), + strengths: c.strengths, + market_position: c.marketPosition, + source: c.source, + })), + updated_at: new Date().toISOString(), + }; + await writeFileWithRetry( + manualCompetitorsPath, + JSON.stringify(manualSerialized, null, 2), + { encoding: "utf-8" } + ); + } + + debugLog("[Roadmap Handler] Saved competitor analysis:", { projectId }); + return { success: true }; + } catch (error) { + debugError("[Roadmap Handler] Failed to save competitor analysis:", error); + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to save competitor analysis", + }; + } + } + ); + // ============================================ // Roadmap Agent Events → Renderer // ============================================ diff --git a/apps/frontend/src/preload/api/modules/roadmap-api.ts b/apps/frontend/src/preload/api/modules/roadmap-api.ts index f5543ed6..40b3f8fb 100644 --- a/apps/frontend/src/preload/api/modules/roadmap-api.ts +++ b/apps/frontend/src/preload/api/modules/roadmap-api.ts @@ -4,6 +4,7 @@ import type { RoadmapFeatureStatus, RoadmapGenerationStatus, PersistedRoadmapProgress, + CompetitorAnalysis, Task, IPCResult } from '../../../shared/types'; @@ -30,6 +31,9 @@ export interface RoadmapAPI { featureId: string ) => Promise>; + // Competitor analysis + saveCompetitorAnalysis: (projectId: string, competitorAnalysis: CompetitorAnalysis) => Promise; + // Progress persistence saveRoadmapProgress: (projectId: string, progress: PersistedRoadmapProgress) => Promise; loadRoadmapProgress: (projectId: string) => Promise>; @@ -86,6 +90,10 @@ export const createRoadmapAPI = (): RoadmapAPI => ({ ): Promise> => invokeIpc(IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC, projectId, featureId), + // Competitor analysis + saveCompetitorAnalysis: (projectId: string, competitorAnalysis: CompetitorAnalysis): Promise => + invokeIpc(IPC_CHANNELS.COMPETITOR_ANALYSIS_SAVE, projectId, competitorAnalysis), + // Progress persistence saveRoadmapProgress: (projectId: string, progress: PersistedRoadmapProgress): Promise => invokeIpc(IPC_CHANNELS.ROADMAP_PROGRESS_SAVE, projectId, progress), diff --git a/apps/frontend/src/renderer/components/AddCompetitorDialog.tsx b/apps/frontend/src/renderer/components/AddCompetitorDialog.tsx new file mode 100644 index 00000000..da309245 --- /dev/null +++ b/apps/frontend/src/renderer/components/AddCompetitorDialog.tsx @@ -0,0 +1,295 @@ +/** + * AddCompetitorDialog - Dialog for adding manual competitors to the roadmap analysis + * + * Allows users to add known competitors with name, URL, description, and relevance. + * Follows the same dialog pattern as AddFeatureDialog for consistency. + * + * Features: + * - Form validation (name and URL required, URL format check) + * - Auto-prepends https:// if protocol is missing + * - Adds competitor to roadmap store and persists via IPC + * + * @example + * ```tsx + * console.log('Competitor added:', id)} + * projectId={projectId} + * /> + * ``` + */ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, AlertCircle } 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 type { CompetitorRelevance } from '../../shared/types'; + +/** + * Props for the AddCompetitorDialog component + */ +interface AddCompetitorDialogProps { + /** Whether the dialog is open */ + open: boolean; + /** Callback when the dialog open state changes */ + onOpenChange: (open: boolean) => void; + /** Optional callback when competitor is successfully added, receives the new competitor ID */ + onCompetitorAdded?: (competitorId: string) => void; + /** Project ID for IPC save */ + projectId: string; +} + +// Relevance options (keys for translation) +const RELEVANCE_OPTIONS = [ + { value: 'high', labelKey: 'addCompetitor.highRelevance' }, + { value: 'medium', labelKey: 'addCompetitor.mediumRelevance' }, + { value: 'low', labelKey: 'addCompetitor.lowRelevance' } +] as const; + +/** + * Basic URL validation - checks for a reasonable URL format + */ +function isValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } +} + +/** + * Normalizes a URL by prepending https:// if no protocol is present + */ +function normalizeUrl(url: string): string { + const trimmed = url.trim(); + if (!trimmed) return trimmed; + if (!/^https?:\/\//i.test(trimmed)) { + return `https://${trimmed}`; + } + return trimmed; +} + +export function AddCompetitorDialog({ + open, + onOpenChange, + onCompetitorAdded, + projectId +}: AddCompetitorDialogProps) { + const { t } = useTranslation('dialogs'); + + // Form state + const [name, setName] = useState(''); + const [url, setUrl] = useState(''); + const [description, setDescription] = useState(''); + const [relevance, setRelevance] = useState('medium'); + + // UI state + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + // Store actions + const addCompetitor = useRoadmapStore((state) => state.addCompetitor); + + // Reset form when dialog opens/closes + useEffect(() => { + if (open) { + setName(''); + setUrl(''); + setDescription(''); + setRelevance('medium'); + setError(null); + } + }, [open]); + + const handleSave = async () => { + // Validate required fields + if (!name.trim()) { + setError(t('addCompetitor.nameRequired')); + return; + } + if (!url.trim()) { + setError(t('addCompetitor.urlRequired')); + return; + } + + const normalizedUrl = normalizeUrl(url); + if (!isValidUrl(normalizedUrl)) { + setError(t('addCompetitor.invalidUrl')); + return; + } + + setIsSaving(true); + setError(null); + + try { + // Capture pre-add state for complete rollback + const previousAnalysis = useRoadmapStore.getState().competitorAnalysis; + + // Add competitor to store + const newCompetitorId = addCompetitor({ + name: name.trim(), + url: normalizedUrl, + description: description.trim(), + relevance + }); + + // Persist to file via IPC + const competitorAnalysis = useRoadmapStore.getState().competitorAnalysis; + if (competitorAnalysis) { + const result = await window.electronAPI.saveCompetitorAnalysis(projectId, competitorAnalysis); + if (!result.success) { + // Rollback store state since save failed + useRoadmapStore.getState().setCompetitorAnalysis(previousAnalysis); + throw new Error(result.error || t('addCompetitor.failedToAdd')); + } + } + + // Success - close dialog and notify parent + onOpenChange(false); + onCompetitorAdded?.(newCompetitorId); + } catch (err) { + setError(err instanceof Error ? err.message : t('addCompetitor.failedToAdd')); + } finally { + setIsSaving(false); + } + }; + + const handleClose = () => { + if (!isSaving) { + onOpenChange(false); + } + }; + + // Form validation + const isValid = name.trim().length > 0 && url.trim().length > 0; + + return ( + + + + {t('addCompetitor.title')} + + {t('addCompetitor.description')} + + + +
+ {/* Name (Required) */} +
+ + setName(e.target.value)} + disabled={isSaving} + aria-required="true" + /> +
+ + {/* URL (Required) */} +
+ + setUrl(e.target.value)} + disabled={isSaving} + aria-required="true" + /> +
+ + {/* Description (Optional) */} +
+ +