diff --git a/app/api/projects/[id]/decisions/[decisionId]/export/route.ts b/app/api/projects/[id]/decisions/[decisionId]/export/route.ts
new file mode 100644
index 0000000..bd01eb0
--- /dev/null
+++ b/app/api/projects/[id]/decisions/[decisionId]/export/route.ts
@@ -0,0 +1,103 @@
+import { NextRequest, NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import {
+ formatDecision,
+ ExportFormat,
+ DecisionExportData,
+} from "@/components/DecisionExportDialog";
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string; decisionId: string }> }
+) {
+ try {
+ const { id: projectId, decisionId } = await params;
+ const { searchParams } = new URL(request.url);
+ const format = (searchParams.get("format") as ExportFormat) || "markdown";
+
+ // Validate format
+ const validFormats = [
+ "cursor",
+ "linear",
+ "jira",
+ "slack",
+ "notion",
+ "markdown",
+ "json",
+ ];
+ if (!validFormats.includes(format)) {
+ return NextResponse.json(
+ { error: `Invalid format. Valid formats: ${validFormats.join(", ")}` },
+ { status: 400 }
+ );
+ }
+
+ // Fetch the decision
+ const decision = await prisma.decision.findUnique({
+ where: { id: decisionId },
+ include: {
+ project: {
+ select: { name: true },
+ },
+ },
+ });
+
+ if (!decision) {
+ return NextResponse.json({ error: "Decision not found" }, { status: 404 });
+ }
+
+ // Verify the decision belongs to the project
+ if (decision.projectId !== projectId) {
+ return NextResponse.json(
+ { error: "Decision does not belong to this project" },
+ { status: 403 }
+ );
+ }
+
+ // Build export data
+ const exportData: DecisionExportData = {
+ id: decision.id,
+ title: decision.title,
+ summary: decision.summary,
+ scope: decision.scope,
+ nonGoals: decision.nonGoals,
+ acceptanceCriteria: decision.acceptanceCriteria,
+ risks: decision.risks,
+ confidenceScore: decision.confidenceScore,
+ linkedFeedbackIds: decision.linkedFeedbackIds,
+ projectName: decision.project.name,
+ };
+
+ // Format the decision
+ const content = formatDecision(exportData, format);
+
+ // Return appropriate response based on format
+ if (format === "json") {
+ return NextResponse.json(JSON.parse(content), {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ }
+
+ // For text formats, return plain text with appropriate headers
+ const contentType =
+ format === "markdown" ? "text/markdown" : "text/plain";
+ const filename = `decision-${decision.title
+ .toLowerCase()
+ .replace(/\s+/g, "-")}.${format === "markdown" ? "md" : format}`;
+
+ return new NextResponse(content, {
+ headers: {
+ "Content-Type": contentType,
+ "Content-Disposition": `attachment; filename="${filename}"`,
+ },
+ });
+ } catch (error) {
+ console.error("Error exporting decision:", error);
+ return NextResponse.json(
+ { error: "Failed to export decision" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/projects/[id]/decisions/route.ts b/app/api/projects/[id]/decisions/route.ts
index 8b21252..728bc71 100644
--- a/app/api/projects/[id]/decisions/route.ts
+++ b/app/api/projects/[id]/decisions/route.ts
@@ -50,7 +50,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
scope: scope || "",
risks: risks || "",
nonGoals: nonGoals || "",
- confidence: confidence || "medium",
+ confidenceScore: confidence || 0.5,
status: "draft",
linkedFeedbackIds: linkedFeedbackIds || [],
},
diff --git a/app/projects/[id]/analysis/page.tsx b/app/projects/[id]/analysis/page.tsx
new file mode 100644
index 0000000..26d08b8
--- /dev/null
+++ b/app/projects/[id]/analysis/page.tsx
@@ -0,0 +1,291 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { ArrowLeft, BrainCircuit, AlertTriangle, TrendingUp, Target, Zap } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { PriorityMatrix } from "@/components/PriorityMatrix";
+import { prisma } from "@/lib/prisma";
+
+export const dynamic = "force-dynamic";
+
+interface OpportunityItem {
+ id: string;
+ title: string;
+ description: string;
+ impact: 'high' | 'medium' | 'low';
+ effort: 'high' | 'medium' | 'low';
+ impactScore: number;
+ feedbackCount: number;
+ category: string;
+}
+
+export default async function AnalysisPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id: projectId } = await params;
+
+ const project = await prisma.project.findUnique({
+ where: { id: projectId },
+ include: {
+ _count: { select: { feedback: true } },
+ analyses: {
+ orderBy: { createdAt: "desc" },
+ take: 1,
+ },
+ },
+ });
+
+ if (!project) {
+ notFound();
+ }
+
+ // Fetch latest analysis
+ const analysis = project.analyses[0];
+
+ if (!analysis) {
+ return (
+
+
+
+
Back to {project.name}
+
+
+
+
+
+ No analysis yet
+
+ Add feedback and run analysis to see opportunities prioritized by impact and effort.
+
+
+
+ Return to Project
+
+
+
+
+
+ );
+ }
+
+ // Parse analysis content
+ const analysisContent = analysis.content as {
+ feedbackCount: number;
+ summary: { bugs: number; features: number; other: number };
+ opportunities: Array<{
+ id: string;
+ title: string;
+ description: string;
+ impact: string;
+ impactScore: number;
+ feedbackCount: number;
+ category: string;
+ }>;
+ unclusteredCount: number;
+ generatedAt: string;
+ };
+
+ const opportunities: OpportunityItem[] = analysisContent.opportunities.map((opp) => ({
+ id: opp.id,
+ title: opp.title,
+ description: opp.description,
+ impact: opp.impact as 'high' | 'medium' | 'low',
+ effort: opp.impactScore >= 0.7 ? 'high' : opp.impactScore >= 0.4 ? 'medium' : 'low',
+ impactScore: opp.impactScore,
+ feedbackCount: opp.feedbackCount,
+ category: opp.category,
+ }));
+
+ // Sort by impact score descending
+ opportunities.sort((a, b) => b.impactScore - a.impactScore);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Back to {project.name}
+
+
+
+
+ AI Analysis
+
+
+ Generated {new Date(analysisContent.generatedAt).toLocaleString()}
+
+
+
+
+
+ Create Decision
+
+
+
+
+ {/* Summary Stats */}
+
+
+
+
+
+
+
+
{analysisContent.feedbackCount}
+
Total Feedback
+
+
+
+
+
+
+
+
{analysisContent.summary.bugs}
+
Bugs Reported
+
+
+
+
+
+
+
+
+
+
{analysisContent.summary.features}
+
Feature Requests
+
+
+
+
+
+
+
+
+
+
{opportunities.length}
+
Opportunities
+
+
+
+
+
+ {/* Tabs for different views */}
+
+
+
+ Priority Matrix
+
+
+ All Opportunities
+
+
+ Unclustered ({analysisContent.unclusteredCount})
+
+
+
+
+
+
+ Eisenhower Priority Matrix
+
+ Prioritize opportunities by Impact (customer value) vs Effort (engineering cost)
+
+
+
+
+
+
+
+
+
+
+
+ All Opportunities
+
+ Ranked by impact score — opportunities with the highest customer value first
+
+
+
+
+ {opportunities.map((opp, index) => (
+
+
+
+
+ #{index + 1}
+ {opp.category}
+
+ {opp.impact} impact
+
+
+ {opp.feedbackCount} votes
+
+
+
{opp.title}
+
{opp.description}
+
+
+
+
+ {Math.round(opp.impactScore * 100)}%
+
+
impact score
+
+
+
+
+ Create Decision
+
+
+
+
+
+ ))}
+ {opportunities.length === 0 && (
+
+ No opportunities identified yet. Add more feedback and re-run analysis.
+
+ )}
+
+
+
+
+
+
+
+
+ Unclustered Feedback
+
+ {analysisContent.unclusteredCount} feedback items couldn't be grouped into themes
+
+
+
+
+ Unclustered feedback details would appear here with options to:
+
+
+ • Review each item manually
+ • Add them to existing opportunities
+ • Create new opportunities from them
+
+
+
+
+
+
+ );
+}
diff --git a/app/projects/[id]/decisions/[decisionId]/page.tsx b/app/projects/[id]/decisions/[decisionId]/page.tsx
new file mode 100644
index 0000000..11c2211
--- /dev/null
+++ b/app/projects/[id]/decisions/[decisionId]/page.tsx
@@ -0,0 +1,202 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { ArrowLeft, Scale, Download, CheckCircle, AlertTriangle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { prisma } from "@/lib/prisma";
+import { DecisionExportDialog } from "@/components/DecisionExportDialog";
+
+export const dynamic = "force-dynamic";
+
+export default async function DecisionDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string; decisionId: string }>;
+}) {
+ const { id: projectId, decisionId } = await params;
+
+ const decision = await prisma.decision.findUnique({
+ where: { id: decisionId },
+ include: {
+ project: {
+ select: { name: true },
+ },
+ },
+ });
+
+ if (!decision || decision.projectId !== projectId) {
+ notFound();
+ }
+
+ const confidencePercent = Math.round(decision.confidenceScore * 100);
+ const confidenceLevel =
+ decision.confidenceScore >= 0.8
+ ? "high"
+ : decision.confidenceScore >= 0.6
+ ? "medium"
+ : "low";
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Back to {decision.project.name}
+
+
+
+ {decision.title}
+
+
+
+ {decision.status.replace("_", " ")}
+
+
+ {confidenceLevel === "high" ? (
+
+ ) : (
+
+ )}
+ {confidencePercent}% confidence
+
+
+
+
+
+
+ {/* Summary */}
+
+
+ Summary
+
+
+
+ {decision.summary}
+
+
+
+
+ {/* Two column layout */}
+
+ {/* Scope */}
+
+
+
+ Scope
+
+
+
+
+ {decision.scope}
+
+
+
+
+ {/* Non-Goals */}
+
+
+ Out of Scope
+
+
+
+ {decision.nonGoals}
+
+
+
+
+
+ {/* Acceptance Criteria */}
+
+
+ Acceptance Criteria
+
+
+
+
+
+
+ {/* Risks */}
+
+
+
+ Risks
+ & Considerations
+
+
+
+
+ {decision.risks}
+
+
+
+
+ {/* Linked Feedback */}
+ {decision.linkedFeedbackIds.length > 0 && (
+
+
+ Linked Feedback
+
+
+
+ This decision addresses {decision.linkedFeedbackIds.length} customer
+ feedback items.
+
+
+ {decision.linkedFeedbackIds.map((feedbackId) => (
+
+ {feedbackId}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/app/projects/[id]/decisions/new/page.tsx b/app/projects/[id]/decisions/new/page.tsx
new file mode 100644
index 0000000..121733e
--- /dev/null
+++ b/app/projects/[id]/decisions/new/page.tsx
@@ -0,0 +1,335 @@
+"use client";
+
+import { useState, use } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { ArrowLeft, Save, Loader2, AlertTriangle, Scale, CheckCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Slider } from "@/components/ui/slider";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+
+interface CreateDecisionPageProps {
+ params: Promise<{ id: string }>;
+}
+
+export default function CreateDecisionPage({ params }: CreateDecisionPageProps) {
+ const resolvedParams = use(params);
+ const projectId = resolvedParams.id;
+ const router = useRouter();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const [formData, setFormData] = useState({
+ title: "",
+ summary: "",
+ scope: "",
+ nonGoals: "",
+ acceptanceCriteria: "",
+ risks: "",
+ confidence: 70,
+ linkedFeedbackIds: "",
+ });
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsSubmitting(true);
+ setError(null);
+
+ try {
+ const response = await fetch(`/api/projects/${projectId}/decisions`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ title: formData.title,
+ summary: formData.summary,
+ scope: formData.scope,
+ nonGoals: formData.nonGoals,
+ acceptanceCriteria: formData.acceptanceCriteria,
+ risks: formData.risks,
+ confidence: formData.confidence / 100, // Convert to 0-1 scale
+ linkedFeedbackIds: formData.linkedFeedbackIds
+ .split(",")
+ .map((id) => id.trim())
+ .filter(Boolean),
+ }),
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || "Failed to create decision");
+ }
+
+ const decision = await response.json();
+
+ // Redirect to the new decision page
+ router.push(`/projects/${projectId}/decisions/${decision.id}`);
+ } catch (err: any) {
+ setError(err.message || "An error occurred while creating the decision");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const getConfidenceLabel = (value: number) => {
+ if (value >= 80) return "High Confidence";
+ if (value >= 60) return "Medium Confidence";
+ if (value >= 40) return "Low Confidence";
+ return "Very Low Confidence";
+ };
+
+ const getConfidenceColor = (value: number) => {
+ if (value >= 80) return "text-green-600";
+ if (value >= 60) return "text-blue-600";
+ if (value >= 40) return "text-yellow-600";
+ return "text-red-600";
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
Back to Decisions
+
+
+
+
+
+ Create New Decision
+
+
+ Document a product decision with full context for your team.
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/projects/[id]/decisions/page.tsx b/app/projects/[id]/decisions/page.tsx
new file mode 100644
index 0000000..d96968f
--- /dev/null
+++ b/app/projects/[id]/decisions/page.tsx
@@ -0,0 +1,166 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { ArrowLeft, Plus, Scale, Download, Filter } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { prisma } from "@/lib/prisma";
+import { DecisionExportDialog, type DecisionExportData } from "@/components/DecisionExportDialog";
+
+export const dynamic = "force-dynamic";
+
+export default async function DecisionsPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id: projectId } = await params;
+
+ const project = await prisma.project.findUnique({
+ where: { id: projectId },
+ select: { name: true },
+ });
+
+ if (!project) {
+ notFound();
+ }
+
+ const decisions = await prisma.decision.findMany({
+ where: { projectId },
+ orderBy: { createdAt: "desc" },
+ });
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Back to {project.name}
+
+
+
+ Decisions
+
+
+ {decisions.length} decision{decisions.length !== 1 ? "s" : ""} created
+
+
+
+
+
+ Filter
+
+
+
+
+ New Decision
+
+
+
+
+
+ {/* Decisions List */}
+ {decisions.length === 0 ? (
+
+
+
+ No decisions yet
+
+ Analyze your feedback first to generate decisions.
+
+
+
+ View Analysis
+
+
+
+
+ ) : (
+
+ {decisions.map((decision) => {
+ const exportData: DecisionExportData = {
+ id: decision.id,
+ title: decision.title,
+ summary: decision.summary,
+ scope: decision.scope,
+ nonGoals: decision.nonGoals,
+ acceptanceCriteria: decision.acceptanceCriteria,
+ risks: decision.risks,
+ confidenceScore: decision.confidenceScore,
+ linkedFeedbackIds: decision.linkedFeedbackIds,
+ projectName: project.name,
+ };
+
+ return (
+
+
+
+
+
+
+
+ {decision.title}
+
+
+
+ {decision.status.replace("_", " ")}
+
+
+
+ {decision.summary}
+
+
+
+ {Math.round(decision.confidenceScore * 100)}% confidence
+
+
+ {decision.linkedFeedbackIds.length} linked feedback
+ {decision.linkedFeedbackIds.length !== 1 ? "s" : ""}
+
+
+ Created {new Date(decision.createdAt).toLocaleDateString()}
+
+
+
+
+
+
+ Export
+
+ }
+ />
+
+
+ View
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/components/DecisionExportDialog.tsx b/components/DecisionExportDialog.tsx
new file mode 100644
index 0000000..fe90926
--- /dev/null
+++ b/components/DecisionExportDialog.tsx
@@ -0,0 +1,482 @@
+"use client";
+
+import { useState } from "react";
+import {
+ Copy,
+ Download,
+ FileCode,
+ MessageSquare,
+ Check,
+ ChevronDown,
+ ExternalLink,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { CopyButton } from "@/components/CopyButton";
+import { cn } from "@/lib/utils";
+
+// Export format definitions
+export type ExportFormat =
+ | "cursor"
+ | "linear"
+ | "jira"
+ | "slack"
+ | "notion"
+ | "markdown"
+ | "json";
+
+export interface DecisionExportData {
+ id: string;
+ title: string;
+ summary: string;
+ scope: string;
+ nonGoals: string;
+ acceptanceCriteria: string;
+ risks: string;
+ confidenceScore: number;
+ linkedFeedbackIds: string[];
+ projectName?: string;
+}
+
+export interface ExportFormatConfig {
+ id: ExportFormat;
+ label: string;
+ description: string;
+ icon: React.ReactNode;
+ color: string;
+}
+
+export const EXPORT_FORMATS: ExportFormatConfig[] = [
+ {
+ id: "cursor",
+ label: "Cursor / Claude Code",
+ description: "AI pair programming handoff",
+ icon: ,
+ color: "text-blue-600 bg-blue-50",
+ },
+ {
+ id: "linear",
+ label: "Linear",
+ description: "Issue tracker format",
+ icon: ,
+ color: "text-purple-600 bg-purple-50",
+ },
+ {
+ id: "jira",
+ label: "Jira",
+ description: "Jira issue format",
+ icon: ,
+ color: "text-blue-700 bg-blue-100",
+ },
+ {
+ id: "slack",
+ label: "Slack",
+ description: "Stakeholder summary",
+ icon: ,
+ color: "text-green-600 bg-green-50",
+ },
+ {
+ id: "notion",
+ label: "Notion",
+ description: "Notion database format",
+ icon: ,
+ color: "text-gray-600 bg-gray-100",
+ },
+ {
+ id: "markdown",
+ label: "Markdown",
+ description: "Documentation format",
+ icon: ,
+ color: "text-gray-700 bg-gray-50",
+ },
+ {
+ id: "json",
+ label: "JSON",
+ description: "Programmatic access",
+ icon: ,
+ color: "text-yellow-600 bg-yellow-50",
+ },
+];
+
+// Formatters for each export format
+function formatAsCursor(data: DecisionExportData): string {
+ return `## Feature: ${data.title}
+
+**Summary:** ${data.summary}
+
+**Confidence Score:** ${Math.round(data.confidenceScore * 100)}%
+
+---
+
+### Scope
+${data.scope}
+
+### Acceptance Criteria
+${data.acceptanceCriteria
+ .split("\n")
+ .filter((line) => line.trim())
+ .map((ac) => `- [ ] ${ac.trim()}`)
+ .join("\n")}
+
+### Risks to Consider
+${data.risks}
+
+### Non-Goals (Out of Scope)
+${data.nonGoals}
+
+### Linked Feedback
+${data.linkedFeedbackIds.length > 0 ? `This decision addresses ${data.linkedFeedbackIds.length} customer feedback items.` : "No direct feedback links."}
+`;
+}
+
+function formatAsLinear(data: DecisionExportData): string {
+ const priority =
+ data.confidenceScore >= 0.8
+ ? "1"
+ : data.confidenceScore >= 0.6
+ ? "2"
+ : "3";
+ return `**${data.title}**
+
+${data.summary}
+
+*Priority:* ${priority}
+*Confidence:* ${Math.round(data.confidenceScore * 100)}%
+
+*Scope:*
+${data.scope}
+
+*Acceptance Criteria:*
+${data.acceptanceCriteria
+ .split("\n")
+ .filter((line) => line.trim())
+ .map((ac) => `- [ ] ${ac.trim()}`)
+ .join("\n")}
+
+*Risks:*
+${data.risks}`;
+}
+
+function formatAsJira(data: DecisionExportData): string {
+ return `h2. ${data.title}
+
+${data.summary}
+
+*Confidence:* ${Math.round(data.confidenceScore * 100)}%
+
+h3. Scope
+${data.scope}
+
+h3. Acceptance Criteria
+${data.acceptanceCriteria}
+
+h3. Risks
+${data.risks}
+
+h3. Out of Scope
+${data.nonGoals}`;
+}
+
+function formatAsSlack(data: DecisionExportData): string {
+ const emoji =
+ data.confidenceScore >= 0.8
+ ? "✅"
+ : data.confidenceScore >= 0.6
+ ? "⚠️"
+ : "🤔";
+ return `${emoji} *New Feature Decision: ${data.title}*
+
+_${data.summary}_
+
+*Confidence:* ${Math.round(data.confidenceScore * 100)}%
+
+*What we're building:*
+${data.scope.slice(0, 200)}${data.scope.length > 200 ? "..." : ""}
+
+*Acceptance Criteria:*
+${data.acceptanceCriteria
+ .split("\n")
+ .filter((line) => line.trim())
+ .slice(0, 3)
+ .map((ac) => `• ${ac.trim()}`)
+ .join("\n")}
+
+*Key Risks:*
+${data.risks.slice(0, 100)}${data.risks.length > 100 ? "..." : ""}`;
+}
+
+function formatAsNotion(data: DecisionExportData): string {
+ return `**${data.title}**
+Status: Ready for Handoff
+Confidence: ${Math.round(data.confidenceScore * 100)}%
+
+*Summary*
+${data.summary}
+
+*Scope*
+${data.scope}
+
+*Acceptance Criteria*
+${data.acceptanceCriteria}
+
+*Risks*
+${data.risks}
+
+*Out of Scope*
+${data.nonGoals}
+
+---
+*Linked Feedback Items:* ${data.linkedFeedbackIds.length}`;
+}
+
+function formatAsMarkdown(data: DecisionExportData): string {
+ return `# ${data.title}
+
+**Status:** Ready for Implementation
+**Confidence:** ${Math.round(data.confidenceScore * 100)}%
+
+## Summary
+${data.summary}
+
+## Scope
+${data.scope}
+
+## Acceptance Criteria
+${data.acceptanceCriteria
+ .split("\n")
+ .filter((line) => line.trim())
+ .map((ac) => `- [ ] ${ac.trim()}`)
+ .join("\n")}
+
+## Risks
+${data.risks}
+
+## Out of Scope
+${data.nonGoals}
+
+## Linked Feedback
+${data.linkedFeedbackIds.length > 0 ? `Addresses ${data.linkedFeedbackIds.length} customer feedback items.` : "No direct feedback links."}
+
+---
+
+*Generated by PM Analyzer*
+${data.projectName ? `Project: ${data.projectName}` : ""}`;
+}
+
+function formatAsJson(data: DecisionExportData): string {
+ return JSON.stringify(
+ {
+ decision: {
+ id: data.id,
+ title: data.title,
+ summary: data.summary,
+ scope: data.scope,
+ nonGoals: data.nonGoals,
+ acceptanceCriteria: data.acceptanceCriteria,
+ risks: data.risks,
+ confidenceScore: data.confidenceScore,
+ linkedFeedbackIds: data.linkedFeedbackIds,
+ status: "ready_for_handoff",
+ },
+ },
+ null,
+ 2
+ );
+}
+
+export function formatDecision(
+ data: DecisionExportData,
+ format: ExportFormat
+): string {
+ switch (format) {
+ case "cursor":
+ return formatAsCursor(data);
+ case "linear":
+ return formatAsLinear(data);
+ case "jira":
+ return formatAsJira(data);
+ case "slack":
+ return formatAsSlack(data);
+ case "notion":
+ return formatAsNotion(data);
+ case "markdown":
+ return formatAsMarkdown(data);
+ case "json":
+ return formatAsJson(data);
+ default:
+ return formatAsMarkdown(data);
+ }
+}
+
+// Component props
+interface DecisionExportDialogProps {
+ decision: DecisionExportData;
+ trigger?: React.ReactNode;
+ onExport?: (format: ExportFormat, content: string) => void;
+}
+
+export function DecisionExportDialog({
+ decision,
+ trigger,
+ onExport,
+}: DecisionExportDialogProps) {
+ const [selectedFormat, setSelectedFormat] = useState("cursor");
+ const [preview, setPreview] = useState("");
+ const [isOpen, setIsOpen] = useState(false);
+
+ // Generate preview when format or decision changes
+ const updatePreview = (format: ExportFormat) => {
+ const content = formatDecision(decision, format);
+ setPreview(content);
+ return content;
+ };
+
+ const handleFormatChange = (format: ExportFormat) => {
+ setSelectedFormat(format);
+ updatePreview(format);
+ };
+
+ const handleExport = (format: ExportFormat) => {
+ const content = updatePreview(format);
+ if (onExport) {
+ onExport(format, content);
+ }
+ };
+
+ const selectedConfig = EXPORT_FORMATS.find((f) => f.id === selectedFormat);
+
+ return (
+
+ {trigger ? (
+
setIsOpen(true)}>{trigger}
+ ) : (
+
{
+ setIsOpen(true);
+ updatePreview(selectedFormat);
+ }}
+ >
+
+ Export
+
+ )}
+
+ {/* Simple modal overlay */}
+ {isOpen && (
+
+
+
+
+
+ Export Decision
+
+ Choose a format and copy or download the export
+
+
+
setIsOpen(false)}
+ >
+ ✕
+
+
+
+
+
+ {/* Format selector */}
+
+
Format:
+
+
+
+
+
+ {EXPORT_FORMATS.map((format) => (
+
+
+ {format.icon}
+ {format.label}
+
+
+ ))}
+
+
+
+
+
+ {selectedConfig?.description}
+
+
+
+
+ {/* Preview area */}
+
+
+ {/* Action buttons */}
+
+
+ {decision.linkedFeedbackIds.length > 0 && (
+
+ Links to {decision.linkedFeedbackIds.length} feedback items
+
+ )}
+
+
+
+ handleExport(selectedFormat)}
+ >
+
+ Download
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+// Export formatter utilities for API use
+export {
+ formatAsCursor,
+ formatAsLinear,
+ formatAsJira,
+ formatAsSlack,
+ formatAsNotion,
+ formatAsMarkdown,
+ formatAsJson,
+};
diff --git a/components/FeedbackList.tsx b/components/FeedbackList.tsx
index 63ec564..cc62d49 100644
--- a/components/FeedbackList.tsx
+++ b/components/FeedbackList.tsx
@@ -11,12 +11,18 @@ import {
Bug,
Terminal,
Trash2,
+ Quote,
+ Sparkles,
+ DollarSign,
+ CheckSquare,
+ Square,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Feedback } from "@/lib/api";
+import { QuickDecisionDialog } from "./QuickDecisionDialog";
interface FeedbackListProps {
feedbacks: Feedback[];
@@ -27,8 +33,11 @@ export function FeedbackList({ feedbacks }: FeedbackListProps) {
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<"all" | "sentry" | "pr">("all");
const [copiedId, setCopiedId] = useState(null);
+ const [copiedQuoteId, setCopiedQuoteId] = useState(null);
const [expandedSpec, setExpandedSpec] = useState(null);
const [deletingId, setDeletingId] = useState(null);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [showQuickDecision, setShowQuickDecision] = useState(false);
useEffect(() => {
setItems(feedbacks);
@@ -47,6 +56,30 @@ export function FeedbackList({ feedbacks }: FeedbackListProps) {
return matchesSearch && matchesFilter;
});
+ const selectedItems = items.filter((f) => selectedIds.has(f.id));
+ const totalSelectedRevenue = selectedItems.reduce(
+ (sum, f) => sum + (f.revenue || 0),
+ 0
+ );
+
+ const toggleSelection = (id: string) => {
+ const newSelected = new Set(selectedIds);
+ if (newSelected.has(id)) {
+ newSelected.delete(id);
+ } else {
+ newSelected.add(id);
+ }
+ setSelectedIds(newSelected);
+ };
+
+ const toggleAll = () => {
+ if (selectedIds.size === filtered.length) {
+ setSelectedIds(new Set());
+ } else {
+ setSelectedIds(new Set(filtered.map((f) => f.id)));
+ }
+ };
+
const handleDelete = async (item: Feedback) => {
const confirmed = window.confirm(
"Delete this feedback? This action cannot be undone."
@@ -69,8 +102,14 @@ export function FeedbackList({ feedbacks }: FeedbackListProps) {
throw new Error(message);
}
setItems((prev) => prev.filter((f) => f.id !== item.id));
+ setSelectedIds((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(item.id);
+ return newSet;
+ });
if (expandedSpec === item.id) setExpandedSpec(null);
if (copiedId === item.id) setCopiedId(null);
+ if (copiedQuoteId === item.id) setCopiedQuoteId(null);
} catch (error: any) {
alert(error.message || "Failed to delete feedback");
} finally {
@@ -94,14 +133,35 @@ export function FeedbackList({ feedbacks }: FeedbackListProps) {
}
};
+ const handleCopyAsQuote = async (item: Feedback) => {
+ try {
+ const customerTier = item.customerTier || "Unknown tier";
+ const source = item.source || "Unknown source";
+ const quote = `> "${item.text}"\n>\n> — ${customerTier} customer via ${source}`;
+ await navigator.clipboard.writeText(quote);
+ setCopiedQuoteId(item.id);
+ setTimeout(() => setCopiedQuoteId(null), 2000);
+ } catch (error: any) {
+ alert(error.message || "Unable to copy quote");
+ }
+ };
+
const getStatusBadge = (status: string) => {
switch (status) {
case "pending_analysis":
return Analyzing... ;
case "analyzed":
- return Ready for Implementation ;
+ return (
+
+ Ready for Implementation
+
+ );
case "ready_for_implementation":
- return Ready for Implementation ;
+ return (
+
+ Ready for Implementation
+
+ );
case "shipped":
return Shipped ;
case "failed":
@@ -111,8 +171,72 @@ export function FeedbackList({ feedbacks }: FeedbackListProps) {
}
};
+ const getRevenueBadge = (revenue: number | null | undefined) => {
+ if (!revenue || revenue === 0) return null;
+ return (
+
+
+ ${revenue.toLocaleString()}/mo
+
+ );
+ };
+
+ const getCustomerTierBadge = (tier: string | null | undefined) => {
+ if (!tier) return null;
+ const colors: Record = {
+ enterprise: "bg-purple-100 border-purple-200 text-purple-700",
+ pro: "bg-blue-100 border-blue-200 text-blue-700",
+ starter: "bg-green-100 border-green-200 text-green-700",
+ };
+ const colorClass = colors[tier.toLowerCase()] || "bg-gray-100 border-gray-200";
+ return (
+
+ {tier}
+
+ );
+ };
+
return (
+ {/* Selection Header */}
+ {selectedIds.size > 0 && (
+
+
+
+
+ {selectedIds.size} item
+ {selectedIds.size !== 1 && "s"} selected
+
+
+ Potential impact:{" "}
+
+ ${totalSelectedRevenue.toLocaleString()}/mo
+
+
+
+
+ setSelectedIds(new Set())}
+ >
+ Clear
+
+ setShowQuickDecision(true)}
+ >
+
+ Create Decision
+
+
+
+
+ )}
+
) : (
- filtered.map((item) => (
-
-
-
-
- {getStatusBadge(item.status)}
- {item.type && (
-
- {item.type === "bug" ? (
-
+ <>
+ {/* Select All Row */}
+
+
+ {selectedIds.size === filtered.length ? (
+
+ ) : (
+
+ )}
+
+
+ Select all ({filtered.length} items)
+
+
+
+ {filtered.map((item) => (
+
+
+
+
+ {/* Selection Checkbox */}
+
toggleSelection(item.id)}
+ >
+ {selectedIds.has(item.id) ? (
+
) : (
- ✨
+
)}
- {item.type}
-
- )}
-
- {item.source} •{" "}
- {new Date(item.createdAt).toLocaleDateString()}
-
-
-
- {item.sentryIssueUrl && (
+
+
+ {getStatusBadge(item.status)}
+ {item.type && (
-
- Sentry
+ {item.type === "bug" ? (
+
+ ) : (
+ ✨
+ )}
+ {item.type}
)}
- {item.githubPrUrl && (
-
+ {item.source} •{" "}
+ {new Date(item.createdAt).toLocaleDateString()}
+
+
+
+ {item.sentryIssueUrl && (
+
+
+ Sentry
+
+ )}
+ {item.githubPrUrl && (
+
+
+ PR
+
+ )}
+
+
+
+
+ {(item.status === "ready_for_implementation" ||
+ item.status === "analyzed") && (
+
+ setExpandedSpec(
+ expandedSpec === item.id ? null : item.id
+ )
+ }
>
-
- PR
-
+
+ {expandedSpec === item.id ? "Hide" : "View"} Spec
+
)}
-
-
-
- {(item.status === "ready_for_implementation" ||
- item.status === "analyzed") && (
-
- setExpandedSpec(
- expandedSpec === item.id ? null : item.id
- )
- }
- >
-
- {expandedSpec === item.id ? "Hide" : "View"} Spec
-
- )}
+ {(item.status === "ready_for_implementation" ||
+ item.status === "analyzed") && (
+
handleCopySpec(item)}
+ disabled={copiedId === item.id}
+ >
+ {copiedId === item.id ? (
+ <>
+ Copied!
+ >
+ ) : (
+ <>
+ Copy for Cursor
+ >
+ )}
+
+ )}
- {(item.status === "ready_for_implementation" ||
- item.status === "analyzed") && (
handleCopySpec(item)}
- disabled={copiedId === item.id}
+ variant="outline"
+ onClick={() => handleCopyAsQuote(item)}
+ disabled={copiedQuoteId === item.id}
+ title="Copy as formatted customer quote"
>
- {copiedId === item.id ? (
+ {copiedQuoteId === item.id ? (
<>
Copied!
>
) : (
<>
- Copy for Cursor
+
Copy Quote
>
)}
- )}
-
- {item.status === "shipped" && item.githubPrUrl && (
-
-
- View PR
-
+
+ {item.status === "shipped" && item.githubPrUrl && (
+
+
+ View PR
+
+
+ )}
+
+ handleDelete(item)}
+ disabled={deletingId === item.id}
+ >
+
+ {deletingId === item.id ? "Deleting..." : "Delete"}
- )}
-
- handleDelete(item)}
- disabled={deletingId === item.id}
- >
-
- {deletingId === item.id ? "Deleting..." : "Delete"}
-
+
-
-
{item.text}
+
+ {item.text}
+
- {expandedSpec === item.id && item.spec && (
-
- )}
+ {expandedSpec === item.id && item.spec && (
+
+
+
+ )}
- {item.sentryIssueUrl && item.errorFrequency && (
-
-
- Affecting{" "}
-
- {item.errorFrequency} events
-
-
- )}
-
-
- ))
+ {item.sentryIssueUrl && item.errorFrequency && (
+
+
+ Affecting{" "}
+
+ {item.errorFrequency} events
+
+
+ )}
+
+
+ ))}
+ >
)}
+
+ {/* Quick Decision Dialog */}
+ {showQuickDecision && (
+ {
+ setShowQuickDecision(false);
+ setSelectedIds(new Set());
+ }}
+ />
+ )}
);
}
@@ -314,51 +519,61 @@ function SpecViewer({ spec }: SpecViewerProps) {
{normalized.userStory && (
User Story:
-
{normalized.userStory}
-
- )}
- {normalized.acceptanceCriteria && normalized.acceptanceCriteria.length > 0 && (
-
-
Acceptance Criteria:
-
- {normalized.acceptanceCriteria.map((crit: string, i: number) => (
-
- •
- {crit}
-
- ))}
-
+
+ {normalized.userStory}
+
)}
+ {normalized.acceptanceCriteria &&
+ normalized.acceptanceCriteria.length > 0 && (
+
+
Acceptance Criteria:
+
+ {normalized.acceptanceCriteria.map((crit: string, i: number) => (
+
+ •
+ {crit}
+
+ ))}
+
+
+ )}
{normalized.technicalNotes && (
Technical Notes:
-
{normalized.technicalNotes}
-
- )}
- {normalized.reproductionSteps && normalized.reproductionSteps.length > 0 && (
-
-
Reproduction Steps:
-
- {normalized.reproductionSteps.map((step: string, i: number) => (
-
- •
- {step}
-
- ))}
-
+
+ {normalized.technicalNotes}
+
)}
+ {normalized.reproductionSteps &&
+ normalized.reproductionSteps.length > 0 && (
+
+
Reproduction Steps:
+
+ {normalized.reproductionSteps.map((step: string, i: number) => (
+
+ •
+ {step}
+
+ ))}
+
+
+ )}
{normalized.expectedBehavior && (
Expected Behavior:
-
{normalized.expectedBehavior}
+
+ {normalized.expectedBehavior}
+
)}
{normalized.actualBehavior && (
Actual Behavior:
-
{normalized.actualBehavior}
+
+ {normalized.actualBehavior}
+
)}
{normalized.assumptions && normalized.assumptions.length > 0 && (
diff --git a/components/PriorityMatrix.tsx b/components/PriorityMatrix.tsx
index 2bbac16..486042d 100644
--- a/components/PriorityMatrix.tsx
+++ b/components/PriorityMatrix.tsx
@@ -1,6 +1,8 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+import { TrendingUp, TrendingDown } from "lucide-react";
interface PriorityItem {
id: string;
@@ -13,86 +15,187 @@ interface PriorityMatrixProps {
items: PriorityItem[];
}
-const quadrantStyles = {
+const quadrantConfig = {
// High Impact
- q1: { title: "Quick Wins", color: "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800 text-green-700 dark:text-green-300" }, // High Impact, Low Effort
- q2: { title: "Major Projects", color: "bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300" }, // High Impact, High Effort
-
+ q1: {
+ title: "Quick Wins",
+ color: "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800 text-green-700 dark:text-green-300",
+ description: "High ROI: Must-do projects with minimal engineering effort."
+ },
+ q2: {
+ title: "Major Projects",
+ color: "bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300",
+ description: "Strategic Bets: High ROI, but require significant time investment."
+ },
// Low Impact
- q3: { title: "Fillers", color: "bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800 text-yellow-700 dark:text-yellow-300" }, // Low Impact, Low Effort
- q4: { title: "Thankless Tasks", color: "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300" }, // Low Impact, High Effort
+ q3: {
+ title: "Fillers",
+ color: "bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800 text-yellow-700 dark:text-yellow-300",
+ description: "Low Priority: Nice-to-haves that can be tackled during downtime."
+ },
+ q4: {
+ title: "Thankless Tasks",
+ color: "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300",
+ description: "Avoid: Low ROI, high resource drain. Revisit later."
+ },
};
+// Helper to determine quadrant based on impact and effort
+function getQuadrantKey(item: PriorityItem): 'q1' | 'q2' | 'q3' | 'q4' {
+ const highImpact = item.impact === 'high';
+ const lowEffort = item.effort === 'low';
+
+ if (highImpact && lowEffort) return 'q1'; // Quick Wins
+ if (highImpact && item.effort === 'high') return 'q2'; // Major Projects
+ if (!highImpact && lowEffort) return 'q3'; // Fillers
+ return 'q4'; // Thankless Tasks (low impact + high effort)
+}
+
+// Score item for sorting within quadrant (higher = more important)
+function getPriorityScore(item: PriorityItem): number {
+ const impactScore = item.impact === 'high' ? 3 : item.impact === 'medium' ? 2 : 1;
+ const effortScore = item.effort === 'low' ? 3 : item.effort === 'medium' ? 2 : 1;
+ return impactScore + effortScore;
+}
+
export function PriorityMatrix({ items }: PriorityMatrixProps) {
// Group items by quadrants
const quadrants = {
- q1: items.filter(i => i.impact === 'high' && i.effort === 'low'), // Quick Wins
- q2: items.filter(i => i.impact === 'high' && i.effort === 'high'), // Major Projects
- q3: items.filter(i => i.impact === 'low' && i.effort === 'low'), // Fillers
- q4: items.filter(i => i.impact === 'low' && i.effort === 'high'), // Thankless Tasks
+ q1: [] as PriorityItem[],
+ q2: [] as PriorityItem[],
+ q3: [] as PriorityItem[],
+ q4: [] as PriorityItem[],
};
-
- // Also handle items that fall into the 'Medium' band
- const mediumImpactItems = items.filter(i => i.impact === 'medium' || i.effort === 'medium');
+ // Categorize all items into quadrants
+ items.forEach((item) => {
+ const key = getQuadrantKey(item);
+ quadrants[key].push(item);
+ });
- const renderQuadrant = (key: keyof typeof quadrants, description: string) => {
- const { title, color } = quadrantStyles[key];
+ // Sort items within each quadrant by priority score (highest first)
+ Object.keys(quadrants).forEach((key) => {
+ quadrants[key as keyof typeof quadrants].sort((a, b) =>
+ getPriorityScore(b) - getPriorityScore(a)
+ );
+ });
+
+ const renderQuadrant = (key: keyof typeof quadrants) => {
+ const config = quadrantConfig[key];
const data = quadrants[key];
return (
-
-
{title}
-
{description}
-
+
+
{config.title}
+
{config.description}
+
{data.length === 0 ? (
-
No items categorized here.
+
No items in this quadrant.
) : (
data.map(item => (
- {item.title}
+
+
{item.title}
+
+
+ {item.impact[0].toUpperCase()}
+
+
+ {item.effort[0].toUpperCase()}
+
+
+
))
)}
+ {data.length > 0 && (
+
+ {data.length} item{data.length !== 1 ? 's' : ''}
+
+ )}
);
};
+ // Calculate matrix balance
+ const totalItems = items.length;
+ const actionableCount = quadrants.q1.length + quadrants.q2.length;
+ const actionablePercent = totalItems > 0 ? Math.round((actionableCount / totalItems) * 100) : 0;
+
return (
-
-
+
+ {/* Balance indicator */}
+ {totalItems > 0 && (
+
+
+ {actionablePercent}% of opportunities are actionable (Quick Wins + Major Projects)
+
+ = 50 ? 'default' : 'secondary'}>
+ {totalItems} total items
+
+
+ )}
+
+
{/* Row 1: High Impact */}
- {renderQuadrant("q1", "High ROI: Must-do projects with minimal engineering effort.")}
- {renderQuadrant("q2", "Strategic Bets: High ROI, but require significant time investment.")}
+
+
+
+ HIGH IMPACT
+
+ {renderQuadrant("q1")}
+
+
+
+
+ HIGH IMPACT
+
+ {renderQuadrant("q2")}
+
{/* Row 2: Low Impact */}
- {renderQuadrant("q3", "Low Priority: Nice-to-haves that can be tackled during downtime.")}
- {renderQuadrant("q4", "Avoid: Low ROI, high resource drain. Revisit later.")}
+
+
+
+ LOW IMPACT
+
+ {renderQuadrant("q3")}
+
+
+
+
+ LOW IMPACT
+
+ {renderQuadrant("q4")}
+
- {mediumImpactItems.length > 0 && (
-
- Medium Priority / Uncategorized
-
- These items fall in the middle of the matrix and require careful consideration.
-
-
- {mediumImpactItems.map(item => (
-
- {item.title}
-
- ))}
-
-
- )}
+ {/* Legend */}
+
+
+
+ Quick Wins (Do First)
+
+
+
+ Major Projects (Plan & Execute)
+
+
+
+ Fillers (Nice to Have)
+
+
+
+ Thankless Tasks (Avoid)
+
+
);
}
\ No newline at end of file
diff --git a/components/QuickDecisionDialog.tsx b/components/QuickDecisionDialog.tsx
new file mode 100644
index 0000000..e38af07
--- /dev/null
+++ b/components/QuickDecisionDialog.tsx
@@ -0,0 +1,376 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import {
+ ArrowLeft,
+ Save,
+ Loader2,
+ AlertTriangle,
+ Scale,
+ CheckCircle,
+ X,
+ Sparkles,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Slider } from "@/components/ui/slider";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import type { Feedback } from "@/lib/api";
+
+interface QuickDecisionDialogProps {
+ projectId: string;
+ selectedFeedback: Feedback[];
+ onClose: () => void;
+}
+
+export function QuickDecisionDialog({
+ projectId,
+ selectedFeedback,
+ onClose,
+}: QuickDecisionDialogProps) {
+ const router = useRouter();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState
(null);
+
+ // Auto-generate title from feedback
+ const generateTitle = () => {
+ const types = selectedFeedback.map((f) => f.type).filter(Boolean);
+ const type = types[0] || "feature";
+ const count = selectedFeedback.length;
+ return `${type === "bug" ? "Fix" : "Implement"} ${count} item${count > 1 ? "s" : ""} from customer feedback`;
+ };
+
+ // Auto-generate summary
+ const generateSummary = () => {
+ const uniqueSources = [...new Set(selectedFeedback.map((f) => f.source).filter(Boolean))];
+ const totalRevenue = selectedFeedback.reduce((sum, f) => sum + (f.revenue || 0), 0);
+ const tiers = [...new Set(selectedFeedback.map((f) => f.customerTier).filter(Boolean))];
+
+ return `Address ${selectedFeedback.length} feedback item${selectedFeedback.length > 1 ? "s" : ""} from ${uniqueSources.join(", ")} (potential impact: $${totalRevenue.toLocaleString()}/mo from ${tiers.join(", ")} customers)`;
+ };
+
+ // Auto-generate scope from feedback text
+ const generateScope = () => {
+ return selectedFeedback
+ .map((f, i) => `${i + 1}. ${f.text}`)
+ .join("\n");
+ };
+
+ const [formData, setFormData] = useState({
+ title: generateTitle(),
+ summary: generateSummary(),
+ scope: generateScope(),
+ nonGoals: "",
+ acceptanceCriteria: "",
+ risks: "",
+ confidence: 70,
+ });
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsSubmitting(true);
+ setError(null);
+
+ try {
+ const response = await fetch(`/api/projects/${projectId}/decisions`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ title: formData.title,
+ summary: formData.summary,
+ scope: formData.scope,
+ nonGoals: formData.nonGoals,
+ acceptanceCriteria: formData.acceptanceCriteria,
+ risks: formData.risks,
+ confidence: formData.confidence / 100,
+ linkedFeedbackIds: selectedFeedback.map((f) => f.id),
+ }),
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || "Failed to create decision");
+ }
+
+ const decision = await response.json();
+ onClose();
+ router.push(`/projects/${projectId}/decisions/${decision.id}`);
+ } catch (err: any) {
+ setError(err.message || "An error occurred while creating the decision");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const getConfidenceLabel = (value: number) => {
+ if (value >= 80) return "High Confidence";
+ if (value >= 60) return "Medium Confidence";
+ if (value >= 40) return "Low Confidence";
+ return "Very Low Confidence";
+ };
+
+ const getConfidenceColor = (value: number) => {
+ if (value >= 80) return "text-green-600";
+ if (value >= 60) return "text-blue-600";
+ if (value >= 40) return "text-yellow-600";
+ return "text-red-600";
+ };
+
+ // Calculate total potential revenue
+ const totalRevenue = selectedFeedback.reduce((sum, f) => sum + (f.revenue || 0), 0);
+ const bugCount = selectedFeedback.filter((f) => f.type === "bug").length;
+ const featureCount = selectedFeedback.filter((f) => f.type === "feature").length;
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
Quick Decision Builder
+
+ Creating decision from {selectedFeedback.length} feedback item
+ {selectedFeedback.length > 1 ? "s" : ""}
+
+
+
+
+
+
+
+
+ {/* Impact Summary */}
+
+
+
+ Total Impact:
+
+ ${totalRevenue.toLocaleString()}/mo
+
+
+
+ Bugs:
+ {bugCount}
+
+
+ Features:
+
+ {featureCount}
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Basic Info */}
+
+
+ Basic Information
+
+ Start with the high-level summary of what you're deciding.
+
+
+
+
+ Decision Title *
+
+ setFormData({ ...formData, title: e.target.value })
+ }
+ required
+ />
+
+
+
+ Summary *
+
+ setFormData({ ...formData, summary: e.target.value })
+ }
+ required
+ rows={3}
+ />
+
+
+
+
+ {/* Scope */}
+
+
+
+ Scope
+
+
+ What exactly will be built or changed? (Pre-filled from feedback)
+
+
+
+
+ setFormData({ ...formData, scope: e.target.value })
+ }
+ rows={5}
+ />
+
+
+
+ {/* Non-Goals */}
+
+
+ Out of Scope
+
+ What won't be included in this decision?
+
+
+
+
+ setFormData({ ...formData, nonGoals: e.target.value })
+ }
+ rows={3}
+ />
+
+
+
+ {/* Acceptance Criteria */}
+
+
+
+ Acceptance Criteria
+
+
+ How will you know this was implemented correctly?
+
+
+
+
+ setFormData({
+ ...formData,
+ acceptanceCriteria: e.target.value,
+ })
+ }
+ rows={4}
+ />
+
+
+
+ {/* Risks */}
+
+
+
+ Risks
+
+ What could go wrong?
+
+
+
+ setFormData({ ...formData, risks: e.target.value })
+ }
+ rows={3}
+ />
+
+
+
+ {/* Confidence */}
+
+
+ Confidence Level
+
+ How confident are you that this is the right decision?
+
+
+
+
+
+
+ {getConfidenceLabel(formData.confidence)}
+
+
+ {formData.confidence}%
+
+
+
+ setFormData({ ...formData, confidence: value })
+ }
+ min={0}
+ max={100}
+ step={5}
+ className="py-4"
+ />
+
+
+
+
+ {/* Actions */}
+
+
+ Cancel
+
+
+ {isSubmitting ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ <>
+
+ Create Decision
+ >
+ )}
+
+
+
+
+
+ );
+}
+
+// Import useRouter at top
+import { useRouter } from "next/navigation";
diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx
new file mode 100644
index 0000000..ab507b5
--- /dev/null
+++ b/components/ui/alert.tsx
@@ -0,0 +1,61 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx
new file mode 100644
index 0000000..ae04b92
--- /dev/null
+++ b/components/ui/slider.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import * as SliderPrimitive from "@radix-ui/react-slider"
+
+import { cn } from "@/lib/utils"
+
+const Slider = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+
+))
+Slider.displayName = SliderPrimitive.Root.displayName
+
+export { Slider }
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000..976cd14
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<"textarea">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+Textarea.displayName = "Textarea"
+
+export { Textarea }