Conversation
## 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)**
- 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
- 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)
- Added Quote icon import from lucide-react
- Added handleCopyAsQuote function that formats feedback as a markdown quote
- Added copiedQuoteId state to track quote copy status
- Added new Copy Quote button next to Copy for Cursor button
- Quote format: > "feedback text" >\n> — {tier} customer via {source}
📝 WalkthroughWalkthroughThis pull request introduces a comprehensive decision management feature, including new Next.js pages for viewing and creating decisions, an analysis dashboard with priority matrix visualization, an export system supporting multiple formats (Markdown, JSON, Jira, Slack, Linear, Notion, Cursor), and supporting UI components (Alert, Slider, Textarea). The changes also update the decision creation API to use numeric confidence scores instead of strings. Changes
Sequence Diagram(s)sequenceDiagram
participant User as User/Client
participant Page as Export Page
participant API as Export API
participant DB as Database<br/>(Prisma)
participant Formatter as Format Engine
participant Response as Response
User->>Page: Request decision export
Page->>Page: Select format (JSON/Markdown/etc)
Page->>API: GET /api/projects/{id}/decisions/{decisionId}/export?format={fmt}
API->>API: Validate format against allowlist
API->>DB: Fetch decision + project name
DB-->>API: Return decision record
API->>API: Validate decision exists & belongs to project
API->>Formatter: formatDecision(data, format)
alt Format = JSON
Formatter-->>API: JSON string
API->>Response: Content-Type: application/json
else Format = Markdown/Text
Formatter-->>API: Formatted text
API->>Response: Content-Type: text/plain<br/>Content-Disposition: attachment
end
Response-->>User: Download/Display content
sequenceDiagram
participant User as User/Client
participant Form as Create Decision Form
participant API as Decisions API
participant DB as Database<br/>(Prisma)
participant Router as Next Router
User->>Form: Fill decision form fields
User->>Form: Click "Create Decision"
Form->>Form: Validate fields & normalize data
Form->>Form: Scale confidence to 0-1 range
Form->>Form: Parse linkedFeedbackIds from input
Form->>API: POST /api/projects/{projectId}/decisions
API->>DB: Create decision record
DB-->>API: Return created decision
API-->>Form: 200 OK + decision data
Form->>Router: Redirect to decision detail page
Router-->>User: Navigate to new decision view
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5dcc40e2a9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const handleExport = (format: ExportFormat) => { | ||
| const content = updatePreview(format); | ||
| if (onExport) { | ||
| onExport(format, content); | ||
| } |
There was a problem hiding this comment.
Provide a default download path in export handler
The Download button currently has no effect unless onExport is passed, because handleExport only invokes the optional callback and does nothing otherwise. In this commit, both current call sites render DecisionExportDialog without onExport (app/projects/[id]/decisions/page.tsx and app/projects/[id]/decisions/[decisionId]/page.tsx), so users can click Download and get no file or network request.
Useful? React with 👍 / 👎.
| risks: risks || "", | ||
| nonGoals: nonGoals || "", | ||
| confidence: confidence || "medium", | ||
| confidenceScore: confidence || 0.5, |
There was a problem hiding this comment.
Keep zero confidence values when creating decisions
Using confidence || 0.5 treats a valid 0 confidence input as falsy and silently rewrites it to 0.5. The new decision form allows selecting 0%, so low-confidence decisions will be persisted as 50%, which corrupts confidence-based ranking and downstream exports. Use a nullish check instead so only missing values get the fallback.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/api/projects/[id]/decisions/route.ts (1)
28-56:⚠️ Potential issue | 🔴 Critical
acceptanceCriteriafrom the request body is never persisted.The create decision form (
decisions/new/page.tsx) sendsacceptanceCriteriain the POST body, but this route handler doesn't destructure it (line 28-36) or include it in the Prismacreatecall (line 45-57). User input for acceptance criteria is silently discarded. The export route and detail page later expectdecision.acceptanceCriteriato be populated.Proposed fix
const { title, summary, scope, risks, nonGoals, confidence, + acceptanceCriteria, linkedFeedbackIds, } = body; ... const decision = await prisma.decision.create({ data: { projectId, title, summary, scope: scope || "", risks: risks || "", nonGoals: nonGoals || "", + acceptanceCriteria: acceptanceCriteria || "", confidenceScore: confidence ?? 0.5, status: "draft", linkedFeedbackIds: linkedFeedbackIds || [], }, });
🤖 Fix all issues with AI agents
In `@app/api/projects/`[id]/decisions/[decisionId]/export/route.ts:
- Around line 86-94: The filename construction uses raw decision.title which
allows header injection; sanitize the title before building filename by
stripping or replacing characters not safe in HTTP headers (e.g., control chars
\r \n, quotes, backslashes, slashes), collapse multiple spaces, and truncate to
a reasonable length; update the code that builds filename (the filename variable
where decision.title is used) to produce a safe ASCII/URL-safe token (or use a
safe fallback like "untitled-decision" when the sanitized result is empty) and
then pass that sanitized filename into the NextResponse Content-Disposition
header to prevent injection.
In `@app/api/projects/`[id]/decisions/route.ts:
- Line 53: The confidence defaulting uses the || operator which treats 0 as
falsy; update the assignment that sets confidenceScore (where confidence is
used) to use the nullish coalescing operator (??) instead of || so only
null/undefined fall back to 0.5, i.e., change the expression that builds
confidenceScore in route.ts to use confidence ?? 0.5.
In `@app/projects/`[id]/analysis/page.tsx:
- Around line 133-178: The summary stats grid uses a fixed "grid-cols-4" which
breaks on small screens; update the container div that currently has className
"grid grid-cols-4 gap-4" to use responsive Tailwind classes (e.g. "grid
grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4") so the Card items (rendered
via Card and CardContent and using analysisContent and opportunities) flow to 1
column on mobile, 2 on small screens, and 4 on larger screens.
- Around line 92-101: The current mapping in page.tsx wrongly derives
opportunity.effort from impactScore, creating a forced correlation; instead add
an independent effortScore (or effort) field to the ClusterResult interface in
lib/clustering.ts and populate it in the clustering logic using a separate
heuristic (e.g., category, complexity signals, or a dedicated effort heuristic)
so clustering outputs an independent effortScore, then change the mapping in
app/projects/[id]/analysis/page.tsx (where OpportunityItem is constructed from
analysisContent.opportunities) to read effort/effortScore from the cluster
result rather than computing it from impactScore, ensuring the Priority Matrix
can produce high-impact/low-effort and low-impact/high-effort quadrants.
In `@app/projects/`[id]/decisions/[decisionId]/page.tsx:
- Line 62: The code uses decision.status.replace("_", " ") which only replaces
the first underscore; update both occurrences of this call (the expressions
using decision.status.replace("_", " ")) to replace all underscores, e.g.
decision.status.replaceAll("_", " ") or decision.status.replace(/_/g, " "), so
multi-underscore statuses like "in_progress_review" become "in progress review";
pick replaceAll if runtime supports it or the regex alternative for broader
compatibility.
In `@components/DecisionExportDialog.tsx`:
- Around line 362-377: The custom trigger path in DecisionExportDialog opens the
dialog without populating the preview because the onClick handler only calls
setIsOpen(true); modify the custom trigger click handler (the element rendering
when trigger is truthy) to also call updatePreview(selectedFormat) after opening
the dialog so it mirrors the default Button path (i.e., onClick should call
setIsOpen(true) and then updatePreview(selectedFormat)).
In `@components/PriorityMatrix.tsx`:
- Line 195: In the PriorityMatrix component, the JSX uses an invalid attribute
on the status indicator span (the <span> with the red dot); replace the DOM
attribute "class" with React's "className" on that element so the tailwind
classes (w-3 h-3 rounded-full bg-red-500) are applied correctly; search for the
span in PriorityMatrix.tsx (the small dot indicator) and update its attribute to
className.
- Around line 43-52: getQuadrantKey currently only branches on 'high' vs 'low'
and drops any 'medium' effort into q4; update getQuadrantKey (and reference
PriorityItem) to explicitly handle effort === 'medium' by introducing boolean
flags (e.g., lowEffort, mediumEffort, highEffort) and then change the branches
so: high impact + lowEffort => 'q1', high impact + (mediumEffort || highEffort)
=> 'q2', non-high impact + (lowEffort || mediumEffort) => 'q3', otherwise 'q4';
this ensures medium efforts are classified correctly for high/medium/low
impacts.
In `@components/ui/slider.tsx`:
- Line 23: The Thumb has conflicting Tailwind border classes (border-slate-200
vs border-slate-900 and dark:border-slate-800 vs dark:border-slate-50); update
the SliderPrimitive.Thumb class list to remove the redundant pairs so there is
only one border class per theme — keep border-slate-900 for light mode and
dark:border-slate-50 for dark mode (remove border-slate-200 and
dark:border-slate-800) in the SliderPrimitive.Thumb component.
🧹 Nitpick comments (11)
app/api/projects/[id]/decisions/[decisionId]/export/route.ts (2)
3-7: Server route imports formatters from a"use client"component file.
DecisionExportDialog.tsxis marked"use client". While this works at runtime (the imported functions don't use hooks or browser APIs), it couples a server-side API route to a client component module. ExtractformatDecision,ExportFormat,DecisionExportData, and the individual formatters into a shared utility (e.g.,lib/decision-export.ts) that both the API route and the dialog can import.
18-33:validFormatsduplicates theExportFormattype — they can drift.Consider deriving validation from the source of truth (e.g., the
EXPORT_FORMATSconstant or theExportFormattype) instead of maintaining a separate hardcoded list.Proposed fix
+import { + formatDecision, + ExportFormat, + DecisionExportData, + EXPORT_FORMATS, +} from "@/components/DecisionExportDialog"; + // Validate format - const validFormats = [ - "cursor", - "linear", - "jira", - "slack", - "notion", - "markdown", - "json", - ]; + const validFormats = EXPORT_FORMATS.map((f) => f.id); if (!validFormats.includes(format)) {app/projects/[id]/decisions/new/page.tsx (2)
46-58: Field name mismatch: the form sendsconfidencebut the export system expectsconfidenceScore.The POST body uses
confidence(line 53) whileDecisionExportDataand the Prisma schema useconfidenceScore. This works today only because the API route manually mapsconfidence→confidenceScore(route.ts line 53), but it's a subtle naming inconsistency that could cause confusion. Consider aligning the field name in the request body with the canonical schema name.
70-71: Useunknowninstead ofanyfor the catch clause.
catch (err: any)bypasses type safety. Withunknown, you can narrow safely.Proposed fix
- } catch (err: any) { - setError(err.message || "An error occurred while creating the decision"); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "An error occurred while creating the decision");components/DecisionExportDialog.tsx (2)
379-398: Modal lacks accessibility: no focus trap, keyboard dismiss, or ARIA attributes.The custom modal overlay is missing:
role="dialog"andaria-modal="true"- Escape key handler to close
- Focus trap (focus remains on background elements)
- Backdrop click to dismiss
Consider using Radix UI's
Dialogprimitive (already a dependency via the Slider) or at minimum add keyboard and ARIA support.
115-298: Formatter functions are co-located with a"use client"component but are pure utility functions.These seven formatters and the
formatDecisiondispatcher have no React dependencies and are imported by the server-side export API route. Moving them to a shared module (e.g.,lib/decision-export.ts) would cleanly separate concerns and avoid importing a client module from server code.app/projects/[id]/decisions/[decisionId]/page.tsx (2)
1-10: Unused import:Download.
Downloadis imported on Line 3 but never used in this file's JSX. It is used in the siblingdecisions/page.tsx, so this appears to be a copy-paste leftover.🧹 Remove unused import
-import { ArrowLeft, Scale, Download, CheckCircle, AlertTriangle } from "lucide-react"; +import { ArrowLeft, Scale, CheckCircle, AlertTriangle } from "lucide-react";
146-161: Acceptance criteria rendering assumes non-null string and uses index keys.If
decision.acceptanceCriteriais evernullor empty, calling.split("\n")would throw. Consider a defensive fallback. Also,readOnlycheckbox without acheckedprop always renders unchecked — if the intent is purely decorative,defaultChecked={false}or a static icon (e.g., aCheckCircle) might convey intent more clearly.app/projects/[id]/decisions/page.tsx (1)
54-57: Filter button is non-functional.This button has no
onClickhandler and no associated filtering logic. Shipping a visible but inert control can confuse users. Consider either wiring it up or removing it until the feature is implemented.app/projects/[id]/analysis/page.tsx (2)
75-90: Unsafe cast ofanalysis.content— no runtime validation.
analysis.contentis a PrismaJsonfield cast directly to a specific shape. If the stored JSON is malformed or from an older schema version (e.g., missingopportunitiesorsummary), this will cause a runtime crash. Consider adding a guard or using a validation library (e.g., Zod) to parse and provide a safe fallback.
268-287: Unclustered tab is a placeholder with no actual feedback data.The tab header shows the unclustered count from analysis data, but the content is static placeholder text. If this is intentional for an upcoming iteration, consider adding a brief inline comment or disabling the tab to set expectations.
| 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}"`, | ||
| }, |
There was a problem hiding this comment.
Unsanitized decision title in Content-Disposition filename — potential header injection.
decision.title is user-supplied and only has whitespace replaced. Characters like ", \n, \r, or / in the title can break or inject into the Content-Disposition header value.
Proposed fix
- const filename = `decision-${decision.title
- .toLowerCase()
- .replace(/\s+/g, "-")}.${format === "markdown" ? "md" : format}`;
+ const safeTitle = decision.title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 100);
+ const ext = format === "markdown" ? "md" : format;
+ const filename = `decision-${safeTitle || "export"}.${ext}`;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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}"`, | |
| }, | |
| const safeTitle = decision.title | |
| .toLowerCase() | |
| .replace(/[^a-z0-9]+/g, "-") | |
| .replace(/^-+|-+$/g, "") | |
| .slice(0, 100); | |
| const ext = format === "markdown" ? "md" : format; | |
| const filename = `decision-${safeTitle || "export"}.${ext}`; | |
| return new NextResponse(content, { | |
| headers: { | |
| "Content-Type": contentType, | |
| "Content-Disposition": `attachment; filename="${filename}"`, | |
| }, |
🤖 Prompt for AI Agents
In `@app/api/projects/`[id]/decisions/[decisionId]/export/route.ts around lines 86
- 94, The filename construction uses raw decision.title which allows header
injection; sanitize the title before building filename by stripping or replacing
characters not safe in HTTP headers (e.g., control chars \r \n, quotes,
backslashes, slashes), collapse multiple spaces, and truncate to a reasonable
length; update the code that builds filename (the filename variable where
decision.title is used) to produce a safe ASCII/URL-safe token (or use a safe
fallback like "untitled-decision" when the sanitized result is empty) and then
pass that sanitized filename into the NextResponse Content-Disposition header to
prevent injection.
| risks: risks || "", | ||
| nonGoals: nonGoals || "", | ||
| confidence: confidence || "medium", | ||
| confidenceScore: confidence || 0.5, |
There was a problem hiding this comment.
Bug: confidence || 0.5 treats a valid 0 confidence as falsy, defaulting it to 0.5.
The || operator falls back on any falsy value including 0. If a user sets the slider to 0%, the stored value will silently become 50%. Use nullish coalescing (??) to only fall back on null/undefined.
Proposed fix
- confidenceScore: confidence || 0.5,
+ confidenceScore: confidence ?? 0.5,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| confidenceScore: confidence || 0.5, | |
| confidenceScore: confidence ?? 0.5, |
🤖 Prompt for AI Agents
In `@app/api/projects/`[id]/decisions/route.ts at line 53, The confidence
defaulting uses the || operator which treats 0 as falsy; update the assignment
that sets confidenceScore (where confidence is used) to use the nullish
coalescing operator (??) instead of || so only null/undefined fall back to 0.5,
i.e., change the expression that builds confidenceScore in route.ts to use
confidence ?? 0.5.
| 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, | ||
| })); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "page.tsx" | grep -E "analysis|projects"Repository: Fresh-Industries/pm-analyzer
Length of output: 681
🏁 Script executed:
cd app/projects && find . -type f -name "page.tsx" 2>/dev/null | head -20Repository: Fresh-Industries/pm-analyzer
Length of output: 486
🏁 Script executed:
fd "page.tsx" app/projects --type fRepository: Fresh-Industries/pm-analyzer
Length of output: 651
🏁 Script executed:
cat -n app/projects/[id]/analysis/page.tsx | head -110Repository: Fresh-Industries/pm-analyzer
Length of output: 4291
🏁 Script executed:
rg -n "opportunities" app/projects/[id]/analysis/page.tsx -B 2 -A 2Repository: Fresh-Industries/pm-analyzer
Length of output: 2968
🏁 Script executed:
# Check where analysis content is generated - look for analysis generation code
fd "analysis" app --type f -e ts -e tsx | grep -v node_modules | head -20Repository: Fresh-Industries/pm-analyzer
Length of output: 54
🏁 Script executed:
# Search for where Analysis data is created/stored to see if effort field exists
rg -n "effort" --type=ts --type=tsx -C 3 | head -100Repository: Fresh-Industries/pm-analyzer
Length of output: 99
🏁 Script executed:
# Search for where Analysis model is used or generated
rg -n "effort" -t ts -C 3 | head -150Repository: Fresh-Industries/pm-analyzer
Length of output: 6587
🏁 Script executed:
# Look for API routes or lib functions that generate analysis
fd -e ts -e tsx | grep -E "(api|lib|agent)" | grep -v node_modules | head -30Repository: Fresh-Industries/pm-analyzer
Length of output: 1284
🏁 Script executed:
# Search for where analysis/opportunities are generated
rg -n "opportunities" -t ts -C 5 | head -200Repository: Fresh-Industries/pm-analyzer
Length of output: 14115
🏁 Script executed:
# Look at the analyze API route to see where analysis content is generated
cat -n app/api/projects/[id]/analyze/route.ts | head -150Repository: Fresh-Industries/pm-analyzer
Length of output: 3726
🏁 Script executed:
# Search for where Analysis data model is defined and used
rg -n "analysis.content" -t ts -C 3Repository: Fresh-Industries/pm-analyzer
Length of output: 1105
🏁 Script executed:
# Find the agent that generates analysis to see if it includes effort
rg -n "opportunities" lib/agents -t ts -C 5 | head -200Repository: Fresh-Industries/pm-analyzer
Length of output: 2825
🏁 Script executed:
# Check the clustering module to see what fields cluster objects have
cat -n lib/clustering.ts | head -200Repository: Fresh-Industries/pm-analyzer
Length of output: 7225
🏁 Script executed:
cat -n lib/clustering.ts | tail -200Repository: Fresh-Industries/pm-analyzer
Length of output: 7668
🏁 Script executed:
# Verify the PriorityMatrix component to confirm the quadrant distribution problem
cat -n components/PriorityMatrix.tsx | head -60Repository: Fresh-Industries/pm-analyzer
Length of output: 2738
effort derivation from impactScore breaks the Eisenhower matrix quadrant distribution.
The clustering module provides no independent effort field. Line 97 derives effort directly from impact score (0.7+ → high, 0.4+ → medium, <0.4 → low), forcing a 1:1 correlation between impact and effort. This makes the Priority Matrix diagonal instead of 2×2: high-impact items always map to high-effort (Q2), low-impact items always to low-effort (Q3), leaving "Quick Wins" (Q1: high impact, low effort) and "Thankless Tasks" (Q4: low impact, high effort) unreachable.
To fix this, add an independent effort or effortScore field to the ClusterResult interface in lib/clustering.ts and calculate it separately in the clustering logic (e.g., based on category, complexity signals, or a dedicated heuristic), rather than deriving it from impact.
🤖 Prompt for AI Agents
In `@app/projects/`[id]/analysis/page.tsx around lines 92 - 101, The current
mapping in page.tsx wrongly derives opportunity.effort from impactScore,
creating a forced correlation; instead add an independent effortScore (or
effort) field to the ClusterResult interface in lib/clustering.ts and populate
it in the clustering logic using a separate heuristic (e.g., category,
complexity signals, or a dedicated effort heuristic) so clustering outputs an
independent effortScore, then change the mapping in
app/projects/[id]/analysis/page.tsx (where OpportunityItem is constructed from
analysisContent.opportunities) to read effort/effortScore from the cluster
result rather than computing it from impactScore, ensuring the Priority Matrix
can produce high-impact/low-effort and low-impact/high-effort quadrants.
| <div className="grid grid-cols-4 gap-4"> | ||
| <Card> | ||
| <CardContent className="p-4 flex items-center gap-3"> | ||
| <div className="p-2 bg-blue-100 rounded-lg"> | ||
| <Target className="w-5 h-5 text-blue-600" /> | ||
| </div> | ||
| <div> | ||
| <p className="text-2xl font-semibold">{analysisContent.feedbackCount}</p> | ||
| <p className="text-sm text-gray-500">Total Feedback</p> | ||
| </div> | ||
| </CardContent> | ||
| </Card> | ||
| <Card> | ||
| <CardContent className="p-4 flex items-center gap-3"> | ||
| <div className="p-2 bg-red-100 rounded-lg"> | ||
| <AlertTriangle className="w-5 h-5 text-red-600" /> | ||
| </div> | ||
| <div> | ||
| <p className="text-2xl font-semibold">{analysisContent.summary.bugs}</p> | ||
| <p className="text-sm text-gray-500">Bugs Reported</p> | ||
| </div> | ||
| </CardContent> | ||
| </Card> | ||
| <Card> | ||
| <CardContent className="p-4 flex items-center gap-3"> | ||
| <div className="p-2 bg-green-100 rounded-lg"> | ||
| <TrendingUp className="w-5 h-5 text-green-600" /> | ||
| </div> | ||
| <div> | ||
| <p className="text-2xl font-semibold">{analysisContent.summary.features}</p> | ||
| <p className="text-sm text-gray-500">Feature Requests</p> | ||
| </div> | ||
| </CardContent> | ||
| </Card> | ||
| <Card> | ||
| <CardContent className="p-4 flex items-center gap-3"> | ||
| <div className="p-2 bg-purple-100 rounded-lg"> | ||
| <Zap className="w-5 h-5 text-purple-600" /> | ||
| </div> | ||
| <div> | ||
| <p className="text-2xl font-semibold">{opportunities.length}</p> | ||
| <p className="text-sm text-gray-500">Opportunities</p> | ||
| </div> | ||
| </CardContent> | ||
| </Card> | ||
| </div> |
There was a problem hiding this comment.
Summary stats grid is not responsive.
grid-cols-4 is applied without responsive breakpoints. On small screens, four columns will be too narrow to be usable.
Proposed fix
- <div className="grid grid-cols-4 gap-4">
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="grid grid-cols-4 gap-4"> | |
| <Card> | |
| <CardContent className="p-4 flex items-center gap-3"> | |
| <div className="p-2 bg-blue-100 rounded-lg"> | |
| <Target className="w-5 h-5 text-blue-600" /> | |
| </div> | |
| <div> | |
| <p className="text-2xl font-semibold">{analysisContent.feedbackCount}</p> | |
| <p className="text-sm text-gray-500">Total Feedback</p> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardContent className="p-4 flex items-center gap-3"> | |
| <div className="p-2 bg-red-100 rounded-lg"> | |
| <AlertTriangle className="w-5 h-5 text-red-600" /> | |
| </div> | |
| <div> | |
| <p className="text-2xl font-semibold">{analysisContent.summary.bugs}</p> | |
| <p className="text-sm text-gray-500">Bugs Reported</p> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardContent className="p-4 flex items-center gap-3"> | |
| <div className="p-2 bg-green-100 rounded-lg"> | |
| <TrendingUp className="w-5 h-5 text-green-600" /> | |
| </div> | |
| <div> | |
| <p className="text-2xl font-semibold">{analysisContent.summary.features}</p> | |
| <p className="text-sm text-gray-500">Feature Requests</p> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardContent className="p-4 flex items-center gap-3"> | |
| <div className="p-2 bg-purple-100 rounded-lg"> | |
| <Zap className="w-5 h-5 text-purple-600" /> | |
| </div> | |
| <div> | |
| <p className="text-2xl font-semibold">{opportunities.length}</p> | |
| <p className="text-sm text-gray-500">Opportunities</p> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| <div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> | |
| <Card> | |
| <CardContent className="p-4 flex items-center gap-3"> | |
| <div className="p-2 bg-blue-100 rounded-lg"> | |
| <Target className="w-5 h-5 text-blue-600" /> | |
| </div> | |
| <div> | |
| <p className="text-2xl font-semibold">{analysisContent.feedbackCount}</p> | |
| <p className="text-sm text-gray-500">Total Feedback</p> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardContent className="p-4 flex items-center gap-3"> | |
| <div className="p-2 bg-red-100 rounded-lg"> | |
| <AlertTriangle className="w-5 h-5 text-red-600" /> | |
| </div> | |
| <div> | |
| <p className="text-2xl font-semibold">{analysisContent.summary.bugs}</p> | |
| <p className="text-sm text-gray-500">Bugs Reported</p> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardContent className="p-4 flex items-center gap-3"> | |
| <div className="p-2 bg-green-100 rounded-lg"> | |
| <TrendingUp className="w-5 h-5 text-green-600" /> | |
| </div> | |
| <div> | |
| <p className="text-2xl font-semibold">{analysisContent.summary.features}</p> | |
| <p className="text-sm text-gray-500">Feature Requests</p> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardContent className="p-4 flex items-center gap-3"> | |
| <div className="p-2 bg-purple-100 rounded-lg"> | |
| <Zap className="w-5 h-5 text-purple-600" /> | |
| </div> | |
| <div> | |
| <p className="text-2xl font-semibold">{opportunities.length}</p> | |
| <p className="text-sm text-gray-500">Opportunities</p> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> |
🤖 Prompt for AI Agents
In `@app/projects/`[id]/analysis/page.tsx around lines 133 - 178, The summary
stats grid uses a fixed "grid-cols-4" which breaks on small screens; update the
container div that currently has className "grid grid-cols-4 gap-4" to use
responsive Tailwind classes (e.g. "grid grid-cols-1 sm:grid-cols-2
md:grid-cols-4 gap-4") so the Card items (rendered via Card and CardContent and
using analysisContent and opportunities) flow to 1 column on mobile, 2 on small
screens, and 4 on larger screens.
| decision.status === "handed_off" ? "default" : "secondary" | ||
| } | ||
| > | ||
| {decision.status.replace("_", " ")} |
There was a problem hiding this comment.
String.replace only replaces the first occurrence.
If a status value ever contains more than one underscore (e.g., "in_progress_review"), only the first _ would be replaced, producing "in progress_review". Use replaceAll or a global regex instead.
The same pattern appears on Line 120 of app/projects/[id]/decisions/page.tsx.
Proposed fix
- {decision.status.replace("_", " ")}
+ {decision.status.replaceAll("_", " ")}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {decision.status.replace("_", " ")} | |
| {decision.status.replaceAll("_", " ")} |
🤖 Prompt for AI Agents
In `@app/projects/`[id]/decisions/[decisionId]/page.tsx at line 62, The code uses
decision.status.replace("_", " ") which only replaces the first underscore;
update both occurrences of this call (the expressions using
decision.status.replace("_", " ")) to replace all underscores, e.g.
decision.status.replaceAll("_", " ") or decision.status.replace(/_/g, " "), so
multi-underscore statuses like "in_progress_review" become "in progress review";
pick replaceAll if runtime supports it or the regex alternative for broader
compatibility.
| <div className="relative"> | ||
| {trigger ? ( | ||
| <div onClick={() => setIsOpen(true)}>{trigger}</div> | ||
| ) : ( | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={() => { | ||
| setIsOpen(true); | ||
| updatePreview(selectedFormat); | ||
| }} | ||
| > | ||
| <Download className="w-4 h-4 mr-2" /> | ||
| Export | ||
| </Button> | ||
| )} |
There was a problem hiding this comment.
Custom trigger opens the dialog with an empty preview.
When trigger is provided, the onClick only calls setIsOpen(true) (line 364) without calling updatePreview. The default button path (line 371) does call updatePreview(selectedFormat), so only the custom-trigger path is affected — users will see a blank preview pane until they change the format dropdown.
Proposed fix
- <div onClick={() => setIsOpen(true)}>{trigger}</div>
+ <div onClick={() => {
+ setIsOpen(true);
+ updatePreview(selectedFormat);
+ }}>{trigger}</div>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="relative"> | |
| {trigger ? ( | |
| <div onClick={() => setIsOpen(true)}>{trigger}</div> | |
| ) : ( | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => { | |
| setIsOpen(true); | |
| updatePreview(selectedFormat); | |
| }} | |
| > | |
| <Download className="w-4 h-4 mr-2" /> | |
| Export | |
| </Button> | |
| )} | |
| <div className="relative"> | |
| {trigger ? ( | |
| <div onClick={() => { | |
| setIsOpen(true); | |
| updatePreview(selectedFormat); | |
| }}>{trigger}</div> | |
| ) : ( | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => { | |
| setIsOpen(true); | |
| updatePreview(selectedFormat); | |
| }} | |
| > | |
| <Download className="w-4 h-4 mr-2" /> | |
| Export | |
| </Button> | |
| )} |
🤖 Prompt for AI Agents
In `@components/DecisionExportDialog.tsx` around lines 362 - 377, The custom
trigger path in DecisionExportDialog opens the dialog without populating the
preview because the onClick handler only calls setIsOpen(true); modify the
custom trigger click handler (the element rendering when trigger is truthy) to
also call updatePreview(selectedFormat) after opening the dialog so it mirrors
the default Button path (i.e., onClick should call setIsOpen(true) and then
updatePreview(selectedFormat)).
| // 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) | ||
| } |
There was a problem hiding this comment.
Logic bug: all medium-effort items fall into q4 ("Thankless Tasks / Avoid").
The classification only handles the extremes ('high' / 'low'), so any item with effort: 'medium' bypasses all specific checks and lands in q4 regardless of impact:
| Impact | Effort | Expected | Actual |
|---|---|---|---|
| high | medium | q1 or q2 | q4 ❌ |
| medium | medium | q3 | q4 ❌ |
| medium | high | q4 | q4 ✓ |
| low | medium | q3 | q4 ❌ |
Proposed fix — treat medium effort like high for impact-dominant sorting
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)
+ const highImpact = item.impact === 'high' || item.impact === 'medium';
+ const lowEffort = item.effort === 'low' || item.effort === 'medium';
+
+ if (highImpact && lowEffort) return 'q1'; // Quick Wins
+ if (highImpact && !lowEffort) return 'q2'; // Major Projects
+ if (!highImpact && lowEffort) return 'q3'; // Fillers
+ return 'q4'; // Thankless Tasks
}Adjust thresholds to match your product's definition of each quadrant. The key point is that 'medium' values must be handled explicitly.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // 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) | |
| } | |
| // Helper to determine quadrant based on impact and effort | |
| function getQuadrantKey(item: PriorityItem): 'q1' | 'q2' | 'q3' | 'q4' { | |
| const highImpact = item.impact === 'high' || item.impact === 'medium'; | |
| const lowEffort = item.effort === 'low' || item.effort === 'medium'; | |
| if (highImpact && lowEffort) return 'q1'; // Quick Wins | |
| if (highImpact && !lowEffort) return 'q2'; // Major Projects | |
| if (!highImpact && lowEffort) return 'q3'; // Fillers | |
| return 'q4'; // Thankless Tasks | |
| } |
🤖 Prompt for AI Agents
In `@components/PriorityMatrix.tsx` around lines 43 - 52, getQuadrantKey currently
only branches on 'high' vs 'low' and drops any 'medium' effort into q4; update
getQuadrantKey (and reference PriorityItem) to explicitly handle effort ===
'medium' by introducing boolean flags (e.g., lowEffort, mediumEffort,
highEffort) and then change the branches so: high impact + lowEffort => 'q1',
high impact + (mediumEffort || highEffort) => 'q2', non-high impact + (lowEffort
|| mediumEffort) => 'q3', otherwise 'q4'; this ensures medium efforts are
classified correctly for high/medium/low impacts.
| Fillers (Nice to Have) | ||
| </div> | ||
| <div className="flex items-center gap-2"> | ||
| <span class="w-3 h-3 rounded-full bg-red-500"></span> |
There was a problem hiding this comment.
class should be className in JSX.
Line 195 uses class instead of className. The styles won't be applied correctly.
Proposed fix
- <span class="w-3 h-3 rounded-full bg-red-500"></span>
+ <span className="w-3 h-3 rounded-full bg-red-500"></span>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <span class="w-3 h-3 rounded-full bg-red-500"></span> | |
| <span className="w-3 h-3 rounded-full bg-red-500"></span> |
🤖 Prompt for AI Agents
In `@components/PriorityMatrix.tsx` at line 195, In the PriorityMatrix component,
the JSX uses an invalid attribute on the status indicator span (the <span> with
the red dot); replace the DOM attribute "class" with React's "className" on that
element so the tailwind classes (w-3 h-3 rounded-full bg-red-500) are applied
correctly; search for the span in PriorityMatrix.tsx (the small dot indicator)
and update its attribute to className.
| <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"> | ||
| <SliderPrimitive.Range className="absolute h-full bg-slate-900 dark:bg-slate-50" /> | ||
| </SliderPrimitive.Track> | ||
| <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border border-slate-200 border-slate-900 bg-white shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-950 disabled:pointer-events-none disabled:opacity-50 dark:border-slate-800 dark:border-slate-50 dark:bg-slate-950 dark:focus-visible:ring-slate-300" /> |
There was a problem hiding this comment.
Conflicting border color classes on the Thumb element.
The Thumb has both border-slate-200 and border-slate-900 (and dark:border-slate-800 with dark:border-slate-50). These conflict — the winning class depends on CSS generation order, which is non-deterministic in Tailwind. Likely one of each pair should be removed.
Proposed fix (assuming the intent is a dark border on light and light border on dark)
- <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border border-slate-200 border-slate-900 bg-white shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-950 disabled:pointer-events-none disabled:opacity-50 dark:border-slate-800 dark:border-slate-50 dark:bg-slate-950 dark:focus-visible:ring-slate-300" />
+ <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border border-slate-900/50 bg-white shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-950 disabled:pointer-events-none disabled:opacity-50 dark:border-slate-50 dark:bg-slate-950 dark:focus-visible:ring-slate-300" />📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border border-slate-200 border-slate-900 bg-white shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-950 disabled:pointer-events-none disabled:opacity-50 dark:border-slate-800 dark:border-slate-50 dark:bg-slate-950 dark:focus-visible:ring-slate-300" /> | |
| <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border border-slate-900/50 bg-white shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-950 disabled:pointer-events-none disabled:opacity-50 dark:border-slate-50 dark:bg-slate-950 dark:focus-visible:ring-slate-300" /> |
🤖 Prompt for AI Agents
In `@components/ui/slider.tsx` at line 23, The Thumb has conflicting Tailwind
border classes (border-slate-200 vs border-slate-900 and dark:border-slate-800
vs dark:border-slate-50); update the SliderPrimitive.Thumb class list to remove
the redundant pairs so there is only one border class per theme — keep
border-slate-900 for light mode and dark:border-slate-50 for dark mode (remove
border-slate-200 and dark:border-slate-800) in the SliderPrimitive.Thumb
component.
What I Built
Added a Copy as Customer Quote button to the FeedbackList component that formats feedback as a ready-to-use customer quote.
Changes
Why It Matters
Saves PMs time when they need to share customer quotes in docs, Slack, or emails.
How to Test
Summary by CodeRabbit
Release Notes
New Features
Improvements