From 17596b3c02339b060db9db047985f29a4ee193a3 Mon Sep 17 00:00:00 2001 From: Nikolas Manuel Date: Sat, 7 Feb 2026 05:06:36 -0600 Subject: [PATCH 1/5] feat: Add Decision Export System with multiple formats ## What Was Built A comprehensive Decision Export System for PM Analyzer that enables product managers to export decisions in multiple formats for different downstream tools. ### Components Created 1. **DecisionExportDialog.tsx** - A reusable export modal component - 7 export formats: Cursor/Claude Code, Linear, Jira, Slack, Notion, Markdown, JSON - Live preview of export format - Copy to clipboard functionality - Download as file 2. **Export API Route** (`/api/projects/[id]/decisions/[decisionId]/export`) - GET endpoint for programmatic export - Supports all 7 formats - Returns appropriate Content-Type headers 3. **Decision Detail Page** (`/projects/[id]/decisions/[decisionId]`) - Full decision view with all metadata - Embedded Export dialog - Confidence score badges - Linked feedback display 4. **Decisions List Page** (`/projects/[id]/decisions`) - All decisions in one place - Export button on each decision card - Quick filtering by status ### Export Formats | Format | Use Case | |--------|----------| | Cursor/Claude Code | AI pair programming handoff with structured prompts | | Linear | Issue tracker format for sprint planning | | Jira | Enterprise issue tracking | | Slack | Stakeholder communication with emoji confidence | | Notion | Documentation and wiki export | | Markdown | General documentation | | JSON | Programmatic access and integration | ### Why This Matters Product managers need to hand off decisions to: - Engineering teams (Linear/Jira) - AI coding assistants (Cursor) - Stakeholders (Slack/Notion) - Documentation systems (Markdown/JSON) This feature bridges the gap between PM Analyzer's decision-making capabilities and the tools teams actually use to build software. --- **Nightly Build (2026-02-07)** --- .../decisions/[decisionId]/export/route.ts | 103 ++++ .../[id]/decisions/[decisionId]/page.tsx | 202 ++++++++ app/projects/[id]/decisions/page.tsx | 166 ++++++ components/DecisionExportDialog.tsx | 482 ++++++++++++++++++ 4 files changed, 953 insertions(+) create mode 100644 app/api/projects/[id]/decisions/[decisionId]/export/route.ts create mode 100644 app/projects/[id]/decisions/[decisionId]/page.tsx create mode 100644 app/projects/[id]/decisions/page.tsx create mode 100644 components/DecisionExportDialog.tsx 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/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 + + +
    + {decision.acceptanceCriteria + .split("\n") + .filter((line) => line.trim()) + .map((criteria, i) => ( +
  • + + {criteria.trim()} +
  • + ))} +
+
+
+ + {/* 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/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 +

+
+
+ + +
+
+ + {/* Decisions List */} + {decisions.length === 0 ? ( + + + +

No decisions yet

+

+ Analyze your feedback first to generate decisions. +

+ +
+
+ ) : ( +
+ {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 + + } + /> + +
+
+
+
+ ); + })} +
+ )} +
+ ); +} 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}
+ ) : ( + + )} + + {/* Simple modal overlay */} + {isOpen && ( +
+ + +
+
+ Export Decision + + Choose a format and copy or download the export + +
+ +
+
+ + + {/* Format selector */} +
+ + + +
+ + {selectedConfig?.description} + +
+
+ + {/* Preview area */} +
+
+                  {preview}
+                
+
+ + {/* Action buttons */} +
+
+ {decision.linkedFeedbackIds.length > 0 && ( + + Links to {decision.linkedFeedbackIds.length} feedback items + + )} +
+
+ + +
+
+
+
+
+ )} +
+ ); +} + +// Export formatter utilities for API use +export { + formatAsCursor, + formatAsLinear, + formatAsJira, + formatAsSlack, + formatAsNotion, + formatAsMarkdown, + formatAsJson, +}; From 631d43684a5465765903e03497411510f2026bf8 Mon Sep 17 00:00:00 2001 From: Nikolas Manuel Date: Sun, 8 Feb 2026 05:06:10 -0600 Subject: [PATCH 2/5] feat(analysis): add priority matrix view and fix medium item bug - Created missing Analysis page at /projects/[id]/analysis/ - Added PriorityMatrix component to display opportunities in Eisenhower matrix format - Fixed bug where items with 'medium' impact/effort weren't appearing in any quadrant - Added balance indicator showing % of actionable opportunities - Added sortable quadrants with priority scoring - Added legend and visual improvements --- app/projects/[id]/analysis/page.tsx | 283 ++++++++++++++++++++++++++++ components/PriorityMatrix.tsx | 195 ++++++++++++++----- 2 files changed, 432 insertions(+), 46 deletions(-) create mode 100644 app/projects/[id]/analysis/page.tsx diff --git a/app/projects/[id]/analysis/page.tsx b/app/projects/[id]/analysis/page.tsx new file mode 100644 index 0000000..97e6860 --- /dev/null +++ b/app/projects/[id]/analysis/page.tsx @@ -0,0 +1,283 @@ +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. +

+ +
+
+
+ ); + } + + // 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()} +

+
+ +
+ + {/* 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

+
+
+
+ ))} + {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/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 From af91826c46c071991c7b5e30234fd091a38a3fec Mon Sep 17 00:00:00 2001 From: Nikolas Manuel Date: Mon, 9 Feb 2026 05:05:57 -0600 Subject: [PATCH 3/5] feat: Add Create Decision page with full form - Created new decision creation page at /projects/[id]/decisions/new - Added form with title, summary, scope, non-goals, acceptance criteria, risks, and confidence slider - Fixed API bug: confidence field was using wrong name - Added UI components: Slider, Alert, Textarea - Added Create Decision buttons to analysis opportunities list - Fixed decision API to use confidenceScore (Float) instead of confidence (String) --- app/api/projects/[id]/decisions/route.ts | 2 +- app/projects/[id]/analysis/page.tsx | 16 +- app/projects/[id]/decisions/new/page.tsx | 335 +++++++++++++++++++++++ components/ui/alert.tsx | 61 +++++ components/ui/slider.tsx | 28 ++ components/ui/textarea.tsx | 22 ++ 6 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 app/projects/[id]/decisions/new/page.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/textarea.tsx 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 index 97e6860..26d08b8 100644 --- a/app/projects/[id]/analysis/page.tsx +++ b/app/projects/[id]/analysis/page.tsx @@ -238,11 +238,19 @@ export default async function AnalysisPage({

{opp.title}

{opp.description}

-
-
- {Math.round(opp.impactScore * 100)}% +
+
+
+ {Math.round(opp.impactScore * 100)}% +
+

impact score

-

impact score

+
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} + + )} + +
+ {/* Basic Info */} + + + Basic Information + + Start with the high-level summary of what you're deciding. + + + +
+ + + setFormData({ ...formData, title: e.target.value }) + } + required + /> +
+ +
+ +