Merge pull request #1814 from AndyMik90/auto-claude/220-add-manual-competitor-functionality-in-roadmap

auto-claude: 220-add-manual-competitor-functionality-in-roadmap
This commit is contained in:
Andy
2026-02-20 11:49:52 +01:00
committed by GitHub
18 changed files with 1038 additions and 153 deletions
@@ -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
@@ -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<string, unknown>) => ({
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<IPCResult> => {
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
// ============================================
@@ -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<IPCResult<Task>>;
// Competitor analysis
saveCompetitorAnalysis: (projectId: string, competitorAnalysis: CompetitorAnalysis) => Promise<IPCResult>;
// Progress persistence
saveRoadmapProgress: (projectId: string, progress: PersistedRoadmapProgress) => Promise<IPCResult>;
loadRoadmapProgress: (projectId: string) => Promise<IPCResult<PersistedRoadmapProgress | null>>;
@@ -86,6 +90,10 @@ export const createRoadmapAPI = (): RoadmapAPI => ({
): Promise<IPCResult<Task>> =>
invokeIpc(IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC, projectId, featureId),
// Competitor analysis
saveCompetitorAnalysis: (projectId: string, competitorAnalysis: CompetitorAnalysis): Promise<IPCResult> =>
invokeIpc(IPC_CHANNELS.COMPETITOR_ANALYSIS_SAVE, projectId, competitorAnalysis),
// Progress persistence
saveRoadmapProgress: (projectId: string, progress: PersistedRoadmapProgress): Promise<IPCResult> =>
invokeIpc(IPC_CHANNELS.ROADMAP_PROGRESS_SAVE, projectId, progress),
@@ -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
* <AddCompetitorDialog
* open={isAddDialogOpen}
* onOpenChange={setIsAddDialogOpen}
* onCompetitorAdded={(id) => 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<CompetitorRelevance>('medium');
// UI state
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[550px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-foreground">{t('addCompetitor.title')}</DialogTitle>
<DialogDescription>
{t('addCompetitor.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-4">
{/* Name (Required) */}
<div className="space-y-2">
<Label htmlFor="add-competitor-name" className="text-sm font-medium text-foreground">
{t('addCompetitor.competitorName')} <span className="text-destructive">*</span>
</Label>
<Input
id="add-competitor-name"
placeholder={t('addCompetitor.competitorNamePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isSaving}
aria-required="true"
/>
</div>
{/* URL (Required) */}
<div className="space-y-2">
<Label htmlFor="add-competitor-url" className="text-sm font-medium text-foreground">
{t('addCompetitor.competitorUrl')} <span className="text-destructive">*</span>
</Label>
<Input
id="add-competitor-url"
placeholder={t('addCompetitor.competitorUrlPlaceholder')}
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={isSaving}
aria-required="true"
/>
</div>
{/* Description (Optional) */}
<div className="space-y-2">
<Label htmlFor="add-competitor-description" className="text-sm font-medium text-foreground">
{t('addCompetitor.competitorDescription')} <span className="text-muted-foreground font-normal">({t('addCompetitor.optional')})</span>
</Label>
<Textarea
id="add-competitor-description"
placeholder={t('addCompetitor.competitorDescriptionPlaceholder')}
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
disabled={isSaving}
/>
</div>
{/* Relevance (Optional) */}
<div className="space-y-2">
<Label htmlFor="add-competitor-relevance" className="text-sm font-medium text-foreground">
{t('addCompetitor.relevance')}
</Label>
<Select
value={relevance}
onValueChange={(value) => setRelevance(value as CompetitorRelevance)}
disabled={isSaving}
>
<SelectTrigger id="add-competitor-relevance">
<SelectValue placeholder={t('addCompetitor.selectRelevance')} />
</SelectTrigger>
<SelectContent>
{RELEVANCE_OPTIONS.map(({ value, labelKey }) => (
<SelectItem key={value} value={value}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</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" role="alert">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
{t('addCompetitor.cancel')}
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !isValid}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('addCompetitor.adding')}
</>
) : (
t('addCompetitor.addCompetitor')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,4 +1,6 @@
import { Search, Globe, AlertTriangle, TrendingUp } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, Globe, AlertTriangle, TrendingUp, UserPlus } from 'lucide-react';
import {
AlertDialog,
AlertDialogContent,
@@ -9,12 +11,15 @@ import {
AlertDialogAction,
AlertDialogCancel,
} from './ui/alert-dialog';
import { Button } from './ui/button';
import { AddCompetitorDialog } from './AddCompetitorDialog';
interface CompetitorAnalysisDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAccept: () => void;
onDecline: () => void;
projectId: string;
}
export function CompetitorAnalysisDialog({
@@ -22,7 +27,19 @@ export function CompetitorAnalysisDialog({
onOpenChange,
onAccept,
onDecline,
projectId,
}: CompetitorAnalysisDialogProps) {
const { t } = useTranslation(['dialogs']);
const [showAddDialog, setShowAddDialog] = useState(false);
const [addedCount, setAddedCount] = useState(0);
// Reset addedCount when dialog reopens
useEffect(() => {
if (open) {
setAddedCount(0);
}
}, [open]);
const handleAccept = () => {
onAccept();
onOpenChange(false);
@@ -33,78 +50,116 @@ export function CompetitorAnalysisDialog({
onOpenChange(false);
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-foreground">
<TrendingUp className="h-5 w-5 text-primary" />
Enable Competitor Analysis?
</AlertDialogTitle>
<AlertDialogDescription className="text-muted-foreground">
Enhance your roadmap with insights from competitor products
</AlertDialogDescription>
</AlertDialogHeader>
const handleCompetitorAdded = (_competitorId: string) => {
setAddedCount((prev) => prev + 1);
};
<div className="py-4 space-y-4">
{/* What it does */}
<div className="rounded-lg bg-primary/5 border border-primary/20 p-4">
<h4 className="text-sm font-medium text-foreground mb-2">
What competitor analysis does:
</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-start gap-2">
<Search className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>Identifies 3-5 main competitors based on your project type</span>
</li>
<li className="flex items-start gap-2">
<Globe className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>
Searches app stores, forums, and social media for user feedback and pain points
</span>
</li>
<li className="flex items-start gap-2">
<TrendingUp className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>
Suggests features that address gaps in competitor products
</span>
</li>
</ul>
return (
<>
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-foreground">
<TrendingUp className="h-5 w-5 text-primary" />
{t('dialogs:competitorAnalysis.title', 'Enable Competitor Analysis?')}
</AlertDialogTitle>
<AlertDialogDescription className="text-muted-foreground">
{t('dialogs:competitorAnalysis.description', 'Enhance your roadmap with insights from competitor products')}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="py-4 space-y-4">
{/* What it does */}
<div className="rounded-lg bg-primary/5 border border-primary/20 p-4">
<h4 className="text-sm font-medium text-foreground mb-2">
{t('dialogs:competitorAnalysis.whatItDoes', 'What competitor analysis does:')}
</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-start gap-2">
<Search className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t('dialogs:competitorAnalysis.identifiesCompetitors', 'Identifies 3-5 main competitors based on your project type')}</span>
</li>
<li className="flex items-start gap-2">
<Globe className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>
{t('dialogs:competitorAnalysis.searchesAppStores', 'Searches app stores, forums, and social media for user feedback and pain points')}
</span>
</li>
<li className="flex items-start gap-2">
<TrendingUp className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>
{t('dialogs:competitorAnalysis.suggestsFeatures', 'Suggests features that address gaps in competitor products')}
</span>
</li>
</ul>
</div>
{/* Privacy notice */}
<div className="rounded-lg bg-warning/10 border border-warning/30 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-warning flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-foreground">
{t('dialogs:competitorAnalysis.webSearchesTitle', 'Web searches will be performed')}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{t('dialogs:competitorAnalysis.webSearchesDescription', 'This feature will perform web searches to gather competitor information. Your project name and type will be used in search queries. No code or sensitive data is shared.')}
</p>
</div>
</div>
</div>
{/* Optional info */}
<p className="text-xs text-muted-foreground">
{t('dialogs:competitorAnalysis.optionalInfo', 'You can generate a roadmap without competitor analysis if you prefer. The roadmap will still be based on your project structure and best practices.')}
</p>
</div>
{/* Privacy notice */}
<div className="rounded-lg bg-warning/10 border border-warning/30 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-warning flex-shrink-0 mt-0.5" />
{/* Add Known Competitors section */}
<div className="border-t border-border pt-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<h4 className="text-sm font-medium text-foreground">
Web searches will be performed
</h4>
<p className="text-xs text-muted-foreground mt-1">
This feature will perform web searches to gather competitor information.
Your project name and type will be used in search queries.
No code or sensitive data is shared.
<p className="text-sm text-muted-foreground">
{t('dialogs:competitorAnalysis.knowYourCompetitors', 'Already know your competitors?')}
</p>
<p className="text-xs text-muted-foreground/70 mt-0.5">
{t('dialogs:competitorAnalysis.addThemDirectly', 'Add them directly to improve analysis accuracy')}
</p>
</div>
<Button
variant="outline"
size="sm"
className="flex items-center gap-1.5"
onClick={() => setShowAddDialog(true)}
>
<UserPlus className="h-3.5 w-3.5" />
{t('dialogs:competitorAnalysis.addKnownCompetitors', 'Add Known Competitors')}
{addedCount > 0 && (
<span className="ml-1 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-medium text-primary-foreground">
{t('dialogs:competitorAnalysis.competitorsAdded', '{{count}} added', { count: addedCount })}
</span>
)}
</Button>
</div>
</div>
{/* Optional info */}
<p className="text-xs text-muted-foreground">
You can generate a roadmap without competitor analysis if you prefer.
The roadmap will still be based on your project structure and best practices.
</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleDecline}>
{t('dialogs:competitorAnalysis.skipAnalysis', 'No, Skip Analysis')}
</AlertDialogCancel>
<AlertDialogAction onClick={handleAccept}>
{t('dialogs:competitorAnalysis.enableAnalysis', 'Yes, Enable Analysis')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleDecline}>
No, Skip Analysis
</AlertDialogCancel>
<AlertDialogAction onClick={handleAccept}>
Yes, Enable Analysis
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AddCompetitorDialog
open={showAddDialog}
onOpenChange={setShowAddDialog}
onCompetitorAdded={handleCompetitorAdded}
projectId={projectId}
/>
</>
);
}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { TrendingUp, ExternalLink, AlertCircle } from 'lucide-react';
import { TrendingUp, ExternalLink, AlertCircle, Plus } from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -8,35 +9,50 @@ import {
DialogDescription,
} from './ui/dialog';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
import { AddCompetitorDialog } from './AddCompetitorDialog';
import type { CompetitorAnalysis } from '../../shared/types';
interface CompetitorAnalysisViewerProps {
analysis: CompetitorAnalysis | null;
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string;
}
export function CompetitorAnalysisViewer({
analysis,
open,
onOpenChange,
projectId,
}: CompetitorAnalysisViewerProps) {
const { t } = useTranslation('common');
const [showAddDialog, setShowAddDialog] = useState(false);
if (!analysis) return null;
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-primary" />
Competitor Analysis Results
{t('competitorAnalysis.analysisResults')}
</DialogTitle>
<DialogDescription>
Analyzed {analysis.competitors.length} competitors to identify market gaps and opportunities
{t('competitorAnalysis.analysisDescription', { count: analysis.competitors.length })}
</DialogDescription>
<Button
variant="outline"
size="sm"
onClick={() => setShowAddDialog(true)}
className="mt-2 self-start"
>
<Plus className="h-4 w-4 mr-1" />
{t('competitorAnalysis.addCompetitor')}
</Button>
</DialogHeader>
<ScrollArea className="flex-1 overflow-auto pr-4" style={{ maxHeight: 'calc(85vh - 120px)' }}>
@@ -51,6 +67,11 @@ export function CompetitorAnalysisViewer({
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold">{competitor.name}</h3>
{competitor.source === 'manual' && (
<Badge variant="outline" className="text-xs">
{t('competitorAnalysis.manualBadge')}
</Badge>
)}
{competitor.marketPosition && (
<Badge variant="secondary" className="text-xs">
{competitor.marketPosition}
@@ -72,7 +93,7 @@ export function CompetitorAnalysisViewer({
aria-label={t('accessibility.visitExternalLink', { name: competitor.name })}
>
<ExternalLink className="h-3 w-3" aria-hidden="true" />
Visit
{t('competitorAnalysis.visit')}
<span className="sr-only">({t('accessibility.opensInNewWindow')})</span>
</a>
)}
@@ -82,12 +103,12 @@ export function CompetitorAnalysisViewer({
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-warning" />
Identified Pain Points ({competitor.painPoints.length})
{t('competitorAnalysis.identifiedPainPoints', { count: competitor.painPoints.length })}
</h4>
<div className="space-y-2">
{competitor.painPoints.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No pain points identified
{t('competitorAnalysis.noPainPointsIdentified')}
</p>
) : (
competitor.painPoints.map((painPoint) => (
@@ -115,21 +136,21 @@ export function CompetitorAnalysisViewer({
{painPoint.source && (
<div className="mt-2">
<span className="text-xs text-muted-foreground">
Source: <span className="italic">{painPoint.source}</span>
{t('competitorAnalysis.source')} <span className="italic">{painPoint.source}</span>
</span>
</div>
)}
{painPoint.frequency && (
<div className="mt-1">
<span className="text-xs text-muted-foreground">
Frequency: {painPoint.frequency}
{t('competitorAnalysis.frequency')} {painPoint.frequency}
</span>
</div>
)}
{painPoint.opportunity && (
<div className="mt-1">
<span className="text-xs text-muted-foreground">
Opportunity:{' '}
{t('competitorAnalysis.opportunity')}{' '}
<span className="font-medium text-foreground">
{painPoint.opportunity}
</span>
@@ -149,11 +170,11 @@ export function CompetitorAnalysisViewer({
{/* Insights Summary */}
{analysis.insightsSummary && (
<div className="rounded-lg bg-primary/5 border border-primary/20 p-4 space-y-3">
<h4 className="text-sm font-semibold">Market Insights Summary</h4>
<h4 className="text-sm font-semibold">{t('competitorAnalysis.marketInsightsSummary')}</h4>
{analysis.insightsSummary.topPainPoints.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Top Pain Points:</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('competitorAnalysis.topPainPoints')}</p>
<ul className="text-sm space-y-1">
{analysis.insightsSummary.topPainPoints.map((point, idx) => (
<li key={idx} className="text-muted-foreground"> {point}</li>
@@ -164,7 +185,7 @@ export function CompetitorAnalysisViewer({
{analysis.insightsSummary.differentiatorOpportunities.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Differentiator Opportunities:</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('competitorAnalysis.differentiatorOpportunities')}</p>
<ul className="text-sm space-y-1">
{analysis.insightsSummary.differentiatorOpportunities.map((opp, idx) => (
<li key={idx} className="text-muted-foreground"> {opp}</li>
@@ -175,7 +196,7 @@ export function CompetitorAnalysisViewer({
{analysis.insightsSummary.marketTrends.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Market Trends:</p>
<p className="text-xs font-medium text-muted-foreground mb-1">{t('competitorAnalysis.marketTrends')}</p>
<ul className="text-sm space-y-1">
{analysis.insightsSummary.marketTrends.map((trend, idx) => (
<li key={idx} className="text-muted-foreground"> {trend}</li>
@@ -189,5 +210,12 @@ export function CompetitorAnalysisViewer({
</ScrollArea>
</DialogContent>
</Dialog>
<AddCompetitorDialog
open={showAddDialog}
onOpenChange={setShowAddDialog}
projectId={projectId}
/>
</>
);
}
@@ -1,4 +1,6 @@
import { Globe, RefreshCw, TrendingUp, CheckCircle } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Globe, RefreshCw, TrendingUp, CheckCircle, UserPlus } from 'lucide-react';
import {
AlertDialog,
AlertDialogContent,
@@ -8,6 +10,7 @@ import {
AlertDialogTitle,
} from './ui/alert-dialog';
import { Button } from './ui/button';
import { AddCompetitorDialog } from './AddCompetitorDialog';
interface ExistingCompetitorAnalysisDialogProps {
open: boolean;
@@ -15,7 +18,9 @@ interface ExistingCompetitorAnalysisDialogProps {
onUseExisting: () => void;
onRunNew: () => void;
onSkip: () => void;
onCompetitorAdded?: (competitorId: string) => void;
analysisDate?: Date;
projectId: string;
}
export function ExistingCompetitorAnalysisDialog({
@@ -24,8 +29,20 @@ export function ExistingCompetitorAnalysisDialog({
onUseExisting,
onRunNew,
onSkip,
onCompetitorAdded,
analysisDate,
projectId,
}: ExistingCompetitorAnalysisDialogProps) {
const { t, i18n } = useTranslation(['dialogs']);
const [showAddDialog, setShowAddDialog] = useState(false);
// Reset child dialog state when this dialog reopens
useEffect(() => {
if (open) {
setShowAddDialog(false);
}
}, [open]);
const handleUseExisting = () => {
onUseExisting();
onOpenChange(false);
@@ -42,8 +59,8 @@ export function ExistingCompetitorAnalysisDialog({
};
const formatDate = (date?: Date) => {
if (!date) return 'recently';
return new Intl.DateTimeFormat('en-US', {
if (!date) return t('dialogs:existingCompetitorAnalysis.recently');
return new Intl.DateTimeFormat(i18n.language, {
month: 'short',
day: 'numeric',
year: 'numeric',
@@ -51,81 +68,112 @@ export function ExistingCompetitorAnalysisDialog({
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-foreground">
<TrendingUp className="h-5 w-5 text-primary" />
Competitor Analysis Options
</AlertDialogTitle>
<AlertDialogDescription className="text-muted-foreground">
This project has an existing competitor analysis from {formatDate(analysisDate)}
</AlertDialogDescription>
</AlertDialogHeader>
<>
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="sm:max-w-[500px]">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-foreground">
<TrendingUp className="h-5 w-5 text-primary" />
{t('dialogs:existingCompetitorAnalysis.title')}
</AlertDialogTitle>
<AlertDialogDescription className="text-muted-foreground">
{t('dialogs:existingCompetitorAnalysis.description', { date: formatDate(analysisDate) })}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="py-4 space-y-3">
{/* Option 1: Use existing (recommended) */}
<button
onClick={handleUseExisting}
className="w-full rounded-lg bg-primary/10 border border-primary/30 p-4 text-left hover:bg-primary/20 transition-colors"
>
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
Use existing analysis
<span className="text-xs text-primary font-normal">(Recommended)</span>
</h4>
<p className="text-xs text-muted-foreground mt-1">
Reuse the competitor insights you already have. Faster and no additional web searches.
</p>
<div className="py-4 space-y-3">
{/* Option 1: Use existing (recommended) */}
<button
type="button"
onClick={handleUseExisting}
className="w-full rounded-lg bg-primary/10 border border-primary/30 p-4 text-left hover:bg-primary/20 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
{t('dialogs:existingCompetitorAnalysis.useExistingTitle')}
<span className="text-xs text-primary font-normal">{t('dialogs:existingCompetitorAnalysis.recommended')}</span>
</h4>
<p className="text-xs text-muted-foreground mt-1">
{t('dialogs:existingCompetitorAnalysis.useExistingDescription')}
</p>
</div>
</div>
</div>
</button>
</button>
{/* Option 2: Run new analysis */}
<button
onClick={handleRunNew}
className="w-full rounded-lg bg-muted/50 border border-border p-4 text-left hover:bg-muted transition-colors"
>
<div className="flex items-start gap-3">
<RefreshCw className="h-5 w-5 text-muted-foreground flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-foreground">
Run new analysis
</h4>
<p className="text-xs text-muted-foreground mt-1">
Perform fresh web searches to get updated competitor information. Takes longer.
</p>
{/* Option 2: Run new analysis */}
<button
type="button"
onClick={handleRunNew}
className="w-full rounded-lg bg-muted/50 border border-border p-4 text-left hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="flex items-start gap-3">
<RefreshCw className="h-5 w-5 text-muted-foreground flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-foreground">
{t('dialogs:existingCompetitorAnalysis.runNewTitle')}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{t('dialogs:existingCompetitorAnalysis.runNewDescription')}
</p>
</div>
</div>
</div>
</button>
</button>
{/* Option 3: Skip */}
<button
onClick={handleSkip}
className="w-full rounded-lg bg-muted/30 border border-border/50 p-4 text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-start gap-3">
<Globe className="h-5 w-5 text-muted-foreground/60 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-muted-foreground">
Skip competitor analysis
</h4>
<p className="text-xs text-muted-foreground/80 mt-1">
Generate roadmap without any competitor insights.
</p>
{/* Option 3: Add known competitors */}
<button
type="button"
onClick={() => setShowAddDialog(true)}
className="w-full rounded-lg bg-muted/50 border border-border p-4 text-left hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="flex items-start gap-3">
<UserPlus className="h-5 w-5 text-muted-foreground flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-foreground">
{t('dialogs:competitorAnalysis.addKnownCompetitors')}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{t('dialogs:competitorAnalysis.addKnownCompetitorsDescription')}
</p>
</div>
</div>
</div>
</button>
</div>
</button>
<AlertDialogFooter className="sm:justify-start">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Option 4: Skip */}
<button
type="button"
onClick={handleSkip}
className="w-full rounded-lg bg-muted/30 border border-border/50 p-4 text-left hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="flex items-start gap-3">
<Globe className="h-5 w-5 text-muted-foreground/60 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-muted-foreground">
{t('dialogs:existingCompetitorAnalysis.skipTitle')}
</h4>
<p className="text-xs text-muted-foreground/80 mt-1">
{t('dialogs:existingCompetitorAnalysis.skipDescription')}
</p>
</div>
</div>
</button>
</div>
<AlertDialogFooter className="sm:justify-start">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t('dialogs:existingCompetitorAnalysis.cancel')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AddCompetitorDialog
open={showAddDialog}
onOpenChange={setShowAddDialog}
onCompetitorAdded={onCompetitorAdded}
projectId={projectId}
/>
</>
);
}
@@ -78,6 +78,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
onOpenChange={setShowCompetitorDialog}
onAccept={handleCompetitorDialogAccept}
onDecline={handleCompetitorDialogDecline}
projectId={projectId}
/>
{/* Dialog for projects WITH existing competitor analysis */}
<ExistingCompetitorAnalysisDialog
@@ -87,6 +88,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
onRunNew={handleRunNewAnalysis}
onSkip={handleSkipAnalysis}
analysisDate={competitorAnalysisDate}
projectId={projectId}
/>
</>
);
@@ -135,6 +137,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
onOpenChange={setShowCompetitorDialog}
onAccept={handleCompetitorDialogAccept}
onDecline={handleCompetitorDialogDecline}
projectId={projectId}
/>
{/* Competitor Analysis Options Dialog (existing analysis) */}
@@ -145,6 +148,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
onRunNew={handleRunNewAnalysis}
onSkip={handleSkipAnalysis}
analysisDate={competitorAnalysisDate}
projectId={projectId}
/>
{/* Competitor Analysis Viewer */}
@@ -152,6 +156,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
analysis={competitorAnalysis}
open={showCompetitorViewer}
onOpenChange={setShowCompetitorViewer}
projectId={projectId}
/>
{/* Add Feature Dialog */}
@@ -62,6 +62,10 @@ const browserMockAPI: ElectronAPI = {
success: true
}),
saveCompetitorAnalysis: async () => ({
success: true
}),
generateRoadmap: (_projectId: string, _enableCompetitorAnalysis?: boolean, _refreshCompetitorAnalysis?: boolean) => {
console.warn('[Browser Mock] generateRoadmap called');
},
@@ -1,6 +1,8 @@
import { create } from 'zustand';
import type {
Competitor,
CompetitorAnalysis,
ManualCompetitorInput,
Roadmap,
RoadmapFeature,
RoadmapFeatureStatus,
@@ -68,6 +70,7 @@ interface RoadmapState {
reorderFeatures: (phaseId: string, featureIds: string[]) => void;
updateFeaturePhase: (featureId: string, newPhaseId: string) => void;
addFeature: (feature: Omit<RoadmapFeature, 'id'>) => string;
addCompetitor: (input: ManualCompetitorInput) => string;
}
const initialGenerationStatus: RoadmapGenerationStatus = {
@@ -242,7 +245,7 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
// Add a new feature to the roadmap
addFeature: (featureData) => {
const newId = `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newId = `feature-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const newFeature: RoadmapFeature = {
...featureData,
id: newId
@@ -261,7 +264,62 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
});
return newId;
}
},
// Add a manual competitor to the competitor analysis
addCompetitor: (input) => {
const newId = `competitor-manual-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const newCompetitor: Competitor = {
id: newId,
name: input.name,
url: input.url,
description: input.description,
relevance: input.relevance,
painPoints: [],
strengths: [],
marketPosition: '',
source: 'manual'
};
set((state) => {
const existing = state.competitorAnalysis;
if (existing) {
return {
competitorAnalysis: {
...existing,
competitors: [...existing.competitors, newCompetitor]
}
};
}
// Create a new CompetitorAnalysis with sensible defaults
return {
competitorAnalysis: {
projectContext: {
projectName: '',
projectType: '',
targetAudience: ''
},
competitors: [newCompetitor],
marketGaps: [],
insightsSummary: {
topPainPoints: [],
differentiatorOpportunities: [],
marketTrends: []
},
researchMetadata: {
searchQueriesUsed: [],
sourcesConsulted: [],
limitations: []
},
createdAt: new Date()
}
};
});
return newId;
},
}));
/**
@@ -111,6 +111,7 @@ export const AUTO_BUILD_PATHS = {
ROADMAP_FILE: 'roadmap.json',
ROADMAP_DISCOVERY: 'roadmap_discovery.json',
COMPETITOR_ANALYSIS: 'competitor_analysis.json',
MANUAL_COMPETITORS: 'manual_competitors.json',
IDEATION_FILE: 'ideation.json',
IDEATION_CONTEXT: 'ideation_context.json',
PROJECT_INDEX: '.auto-claude/project_index.json',
@@ -185,6 +185,7 @@ export const IPC_CHANNELS = {
ROADMAP_STOP: 'roadmap:stop',
ROADMAP_UPDATE_FEATURE: 'roadmap:updateFeature',
ROADMAP_CONVERT_TO_SPEC: 'roadmap:convertToSpec',
COMPETITOR_ANALYSIS_SAVE: 'roadmap:competitorAnalysisSave',
// Roadmap events (main -> renderer)
ROADMAP_PROGRESS: 'roadmap:progress',
@@ -1,4 +1,22 @@
{
"competitorAnalysis": {
"addCompetitor": "Add Competitor",
"manualBadge": "Manual",
"noCompetitorsYet": "No competitors added yet",
"addCompetitorToStart": "Add a competitor to get started",
"analysisResults": "Competitor Analysis Results",
"analysisDescription": "Analyzed {{count}} competitors to identify market gaps and opportunities",
"visit": "Visit",
"identifiedPainPoints": "Identified Pain Points ({{count}})",
"noPainPointsIdentified": "No pain points identified",
"source": "Source:",
"frequency": "Frequency:",
"opportunity": "Opportunity:",
"marketInsightsSummary": "Market Insights Summary",
"topPainPoints": "Top Pain Points:",
"differentiatorOpportunities": "Differentiator Opportunities:",
"marketTrends": "Market Trends:"
},
"projectTab": {
"settings": "Project settings",
"showArchived": "Show archived",
@@ -166,6 +166,60 @@
"readOnlyVolumeTitle": "Cannot install from disk image",
"readOnlyVolumeDescription": "Please move Auto Claude to your Applications folder before updating."
},
"addCompetitor": {
"title": "Add Competitor",
"description": "Add a known competitor to your analysis...",
"competitorName": "Competitor Name",
"competitorNamePlaceholder": "e.g. Slack, Notion, Figma",
"competitorUrl": "Website URL",
"competitorUrlPlaceholder": "e.g. https://example.com",
"competitorDescription": "Description",
"competitorDescriptionPlaceholder": "Brief description of what this competitor does...",
"relevance": "Relevance",
"selectRelevance": "Select relevance",
"highRelevance": "High - Direct competitor",
"mediumRelevance": "Medium - Partial overlap",
"lowRelevance": "Low - Tangential",
"nameRequired": "Competitor name is required",
"urlRequired": "Website URL is required",
"invalidUrl": "Please enter a valid URL",
"optional": "optional",
"cancel": "Cancel",
"adding": "Adding...",
"addCompetitor": "Add Competitor",
"failedToAdd": "Failed to add competitor"
},
"competitorAnalysis": {
"title": "Enable Competitor Analysis?",
"description": "Enhance your roadmap with insights from competitor products",
"whatItDoes": "What competitor analysis does:",
"identifiesCompetitors": "Identifies 3-5 main competitors based on your project type",
"searchesAppStores": "Searches app stores, forums, and social media for user feedback and pain points",
"suggestsFeatures": "Suggests features that address gaps in competitor products",
"webSearchesTitle": "Web searches will be performed",
"webSearchesDescription": "This feature will perform web searches to gather competitor information. Your project name and type will be used in search queries. No code or sensitive data is shared.",
"optionalInfo": "You can generate a roadmap without competitor analysis if you prefer. The roadmap will still be based on your project structure and best practices.",
"skipAnalysis": "No, Skip Analysis",
"enableAnalysis": "Yes, Enable Analysis",
"knowYourCompetitors": "Already know your competitors?",
"addThemDirectly": "Add them directly to improve analysis accuracy",
"addKnownCompetitors": "Add Known Competitors",
"addKnownCompetitorsDescription": "Manually add competitors you already know about to the existing analysis.",
"competitorsAdded": "{{count}} added"
},
"existingCompetitorAnalysis": {
"title": "Competitor Analysis Options",
"description": "This project has an existing competitor analysis from {{date}}",
"recently": "recently",
"useExistingTitle": "Use existing analysis",
"recommended": "(Recommended)",
"useExistingDescription": "Reuse the competitor insights you already have. Faster and no additional web searches.",
"runNewTitle": "Run new analysis",
"runNewDescription": "Perform fresh web searches to get updated competitor information. Takes longer.",
"skipTitle": "Skip competitor analysis",
"skipDescription": "Generate roadmap without any competitor insights.",
"cancel": "Cancel"
},
"versionWarning": {
"title": "Action Required",
"subtitle": "Version 2.7.5 Update",
@@ -1,4 +1,22 @@
{
"competitorAnalysis": {
"addCompetitor": "Ajouter un concurrent",
"manualBadge": "Manuel",
"noCompetitorsYet": "Aucun concurrent ajouté pour l'instant",
"addCompetitorToStart": "Ajoutez un concurrent pour commencer",
"analysisResults": "Résultats de l'analyse concurrentielle",
"analysisDescription": "{{count}} concurrents analysés pour identifier les opportunités et lacunes du marché",
"visit": "Visiter",
"identifiedPainPoints": "Points de douleur identifiés ({{count}})",
"noPainPointsIdentified": "Aucun point de douleur identifié",
"source": "Source :",
"frequency": "Fréquence :",
"opportunity": "Opportunité :",
"marketInsightsSummary": "Résumé des perspectives du marché",
"topPainPoints": "Principaux points de douleur :",
"differentiatorOpportunities": "Opportunités de différenciation :",
"marketTrends": "Tendances du marché :"
},
"projectTab": {
"settings": "Paramètres du projet",
"showArchived": "Afficher archivés",
@@ -166,6 +166,60 @@
"readOnlyVolumeTitle": "Impossible d'installer depuis une image disque",
"readOnlyVolumeDescription": "Veuillez déplacer Auto Claude dans votre dossier Applications avant de mettre à jour."
},
"addCompetitor": {
"title": "Ajouter un concurrent",
"description": "Ajoutez un concurrent connu à votre analyse...",
"competitorName": "Nom du concurrent",
"competitorNamePlaceholder": "ex. Slack, Notion, Figma",
"competitorUrl": "URL du site web",
"competitorUrlPlaceholder": "ex. https://example.com",
"competitorDescription": "Description",
"competitorDescriptionPlaceholder": "Brève description de ce que fait ce concurrent...",
"relevance": "Pertinence",
"selectRelevance": "Sélectionner la pertinence",
"highRelevance": "Élevée - Concurrent direct",
"mediumRelevance": "Moyenne - Chevauchement partiel",
"lowRelevance": "Faible - Tangentiel",
"nameRequired": "Le nom du concurrent est requis",
"urlRequired": "L'URL du site web est requise",
"invalidUrl": "Veuillez entrer une URL valide",
"optional": "optionnel",
"cancel": "Annuler",
"adding": "Ajout en cours...",
"addCompetitor": "Ajouter le concurrent",
"failedToAdd": "Échec de l'ajout du concurrent"
},
"competitorAnalysis": {
"title": "Activer l'analyse concurrentielle ?",
"description": "Améliorez votre feuille de route avec des informations sur les produits concurrents",
"whatItDoes": "Ce que fait l'analyse concurrentielle :",
"identifiesCompetitors": "Identifie 3 à 5 concurrents principaux en fonction de votre type de projet",
"searchesAppStores": "Recherche dans les magasins d'applications, forums et réseaux sociaux les retours et points de douleur des utilisateurs",
"suggestsFeatures": "Suggère des fonctionnalités qui comblent les lacunes des produits concurrents",
"webSearchesTitle": "Des recherches web seront effectuées",
"webSearchesDescription": "Cette fonctionnalité effectuera des recherches web pour recueillir des informations sur les concurrents. Le nom et le type de votre projet seront utilisés dans les requêtes de recherche. Aucun code ni donnée sensible n'est partagé.",
"optionalInfo": "Vous pouvez générer une feuille de route sans analyse concurrentielle si vous préférez. La feuille de route sera toujours basée sur la structure de votre projet et les meilleures pratiques.",
"skipAnalysis": "Non, ignorer l'analyse",
"enableAnalysis": "Oui, activer l'analyse",
"knowYourCompetitors": "Vous connaissez déjà vos concurrents ?",
"addThemDirectly": "Ajoutez-les directement pour améliorer la précision de l'analyse",
"addKnownCompetitors": "Ajouter des concurrents connus",
"addKnownCompetitorsDescription": "Ajoutez manuellement les concurrents que vous connaissez déjà à l'analyse existante.",
"competitorsAdded": "{{count}} ajouté(s)"
},
"existingCompetitorAnalysis": {
"title": "Options d'analyse concurrentielle",
"description": "Ce projet a une analyse concurrentielle existante du {{date}}",
"recently": "récemment",
"useExistingTitle": "Utiliser l'analyse existante",
"recommended": "(Recommandé)",
"useExistingDescription": "Réutilisez les informations concurrentielles que vous avez déjà. Plus rapide et sans recherches web supplémentaires.",
"runNewTitle": "Lancer une nouvelle analyse",
"runNewDescription": "Effectuer de nouvelles recherches web pour obtenir des informations concurrentielles à jour. Prend plus de temps.",
"skipTitle": "Ignorer l'analyse concurrentielle",
"skipDescription": "Générer la feuille de route sans informations concurrentielles.",
"cancel": "Annuler"
},
"versionWarning": {
"title": "Action requise",
"subtitle": "Mise à jour version 2.7.5",
+2
View File
@@ -108,6 +108,7 @@ import type {
InsightsModelConfig
} from './insights';
import type {
CompetitorAnalysis,
Roadmap,
RoadmapFeatureStatus,
RoadmapGenerationStatus,
@@ -416,6 +417,7 @@ export interface ElectronAPI {
getRoadmap: (projectId: string) => Promise<IPCResult<Roadmap | null>>;
getRoadmapStatus: (projectId: string) => Promise<IPCResult<{ isRunning: boolean }>>;
saveRoadmap: (projectId: string, roadmap: Roadmap) => Promise<IPCResult>;
saveCompetitorAnalysis: (projectId: string, competitorAnalysis: CompetitorAnalysis) => Promise<IPCResult>;
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean) => void;
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean) => void;
stopRoadmap: (projectId: string) => Promise<IPCResult>;
@@ -6,6 +6,7 @@
// Competitor Analysis Types
// ============================================
export type CompetitorSource = 'manual' | 'ai';
export type CompetitorRelevance = 'high' | 'medium' | 'low';
export type PainPointSeverity = 'high' | 'medium' | 'low';
export type OpportunitySize = 'high' | 'medium' | 'low';
@@ -28,6 +29,14 @@ export interface Competitor {
painPoints: CompetitorPainPoint[];
strengths: string[];
marketPosition: string;
source?: CompetitorSource;
}
export interface ManualCompetitorInput {
name: string;
url: string;
description: string;
relevance: CompetitorRelevance;
}
export interface CompetitorMarketGap {