feat: Quick Decision Builder with bulk selection#6
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}
- Add QuickDecisionDialog component for creating decisions from selected feedback - Update FeedbackList with checkbox selection and bulk actions - Show revenue impact badges ($/mo) on feedback items - Show customer tier badges (Enterprise/Pro/Starter) - Calculate total potential revenue when items are selected - Pre-fill decision form with linked feedback content
📝 WalkthroughWalkthroughThis PR introduces a comprehensive decision management system with creation, viewing, and export capabilities. It adds pages for listing, creating, and analyzing decisions; implements export to seven formats (cursor, linear, jira, slack, notion, markdown, json); enhances feedback selection with multi-item support; and includes new UI components for forms, dialogs, and priority visualization. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant Dialog as DecisionExportDialog
participant API as /api/decisions/[id]/export
participant DB as Prisma Database
participant Formatter as formatDecision()
User->>Dialog: Opens export dialog
Dialog->>Dialog: Display format options
User->>Dialog: Selects format (e.g., 'jira')
Dialog->>Dialog: Live preview updates
User->>Dialog: Clicks Download
Dialog->>API: GET ?format=jira
API->>DB: Fetch decision by ID
DB-->>API: Return decision + project
API->>API: Validate ownership
API->>Formatter: formatDecision(data, 'jira')
Formatter-->>API: Return formatted string
API-->>Dialog: Return 200 + formatted content
Dialog-->>User: Trigger file download
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 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: ddfed6af73
ℹ️ 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".
| import { | ||
| formatDecision, | ||
| ExportFormat, | ||
| DecisionExportData, | ||
| } from "@/components/DecisionExportDialog"; |
There was a problem hiding this comment.
Move export formatter out of the client component
This route imports formatDecision from components/DecisionExportDialog.tsx, but that module is marked with "use client" and pulls in client-only React/UI dependencies. Calling a client export from a server route handler is not safe in Next’s server runtime, so /api/projects/[id]/decisions/[decisionId]/export can fail when invoked. The formatter should live in a server-safe shared module (and be imported by both the dialog and the route) instead of importing from a client component file.
Useful? React with 👍 / 👎.
| const handleExport = (format: ExportFormat) => { | ||
| const content = updatePreview(format); | ||
| if (onExport) { | ||
| onExport(format, content); | ||
| } |
There was a problem hiding this comment.
Add download behavior when no onExport callback is passed
The Download button only calls onExport when that optional prop exists, and otherwise performs no action. In this commit, both usages of DecisionExportDialog omit onExport (app/projects/[id]/decisions/page.tsx and app/projects/[id]/decisions/[decisionId]/page.tsx), so users click “Download” and nothing is exported. Add a fallback path (e.g., direct file download or calling the new export API) when onExport is undefined.
Useful? React with 👍 / 👎.
| risks: risks || "", | ||
| nonGoals: nonGoals || "", | ||
| confidence: confidence || "medium", | ||
| confidenceScore: confidence || 0.5, |
There was a problem hiding this comment.
Preserve explicit zero confidence values
Using confidence || 0.5 overwrites a valid 0 input with 0.5 because 0 is falsy. The new decision forms send confidence on a 0–1 scale from the slider, so choosing 0% confidence is currently stored as 50%, which corrupts saved decision data and downstream exports/displays. Use a nullish check (??) or explicit numeric validation so 0 is persisted correctly.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 19
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
acceptanceCriteriais silently dropped — data loss bug.Both
QuickDecisionDialogand the "New Decision" form sendacceptanceCriteriain the POST body, but the route handler neither destructures it nor saves it to the database. Users can enter this field, but it is silently discarded.Additionally, line 53 uses
confidence || 0.5, which treats 0 as falsy and incorrectly converts a valid confidence score of 0 to 0.5. Use the nullish coalescing operator instead:confidence ?? 0.5.Proposed fix
const { title, summary, scope, risks, nonGoals, confidence, linkedFeedbackIds, + acceptanceCriteria, } = body;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-88: The generated filename built in the filename constant uses
raw decision.title and only strips whitespace, which can introduce quotes,
slashes, backslashes, and other characters that break the Content-Disposition
header; update the logic that builds filename (the filename const) to: sanitize
decision.title by removing or replacing all characters except ASCII letters,
numbers, hyphen and underscore (collapse runs and trim, and cap length to e.g.
100 chars), percent-encode or provide a RFC5987-safe fallback when setting
headers, and normalize extensions so format === "markdown" => ".md" and all
other formats use ".txt" (avoid producing non-standard extensions like
.cursor/.linear). Ensure you reference and change the filename construction and
the code that sets the Content-Disposition header to use the sanitized/encoded
filename.
- Around line 9-103: Add an authentication and authorization check at the start
of the exported GET handler: call getServerSession() from lib/auth-server.ts and
if it returns no session return a 401 JSON response; then verify the signed-in
user is allowed to access the decision by ensuring they belong to the project
(e.g. query the project using prisma (prisma.project.findFirst or findUnique
with membership/owner check) and confirm project.id === projectId and that
session.user.id is an owner/member), and only proceed to load the Decision (or
return 403) if that authorization check passes; keep the existing
decision.projectId === projectId validation as a secondary data check.
In `@app/api/projects/`[id]/decisions/route.ts:
- Line 53: The code sets confidenceScore using the falsy-check operator
(confidence || 0.5), which treats 0 as falsy; update the assignment to use
nullish coalescing so only null/undefined fall back to 0.5 (replace the use of
|| with ?? for the confidence -> confidenceScore mapping), referencing the
confidence variable and confidenceScore field in the route handler that builds
the decision payload.
In `@app/projects/`[id]/analysis/page.tsx:
- Line 133: The grid container currently uses a fixed "grid grid-cols-4 gap-4"
which forces four columns on all viewports; update the className on that div
(the element with "grid grid-cols-4 gap-4") to use responsive Tailwind
breakpoints (for example: single column on xs, then 2 cols on small, 3 on
medium, 4 on large) so it collapses on small screens and expands on larger ones.
- Around line 92-101: The mapping for opportunities incorrectly derives effort
from impactScore in the opportunities.map block (constructing OpportunityItem),
which prevents valid Priority Matrix quadrants; change the logic to source
effort from the analysis payload (use opp.effort if present) and only fallback
to a neutral default like 'medium' (or an independent estimator) instead of
using opp.impactScore; update the mapping in the same function where
opportunities are created and ensure the OpportunityItem type/shape (and any
downstream UI expecting effort) supports this field.
In `@app/projects/`[id]/decisions/[decisionId]/page.tsx:
- Line 62: The code uses decision.status.replace("_", " ") which only replaces
the first underscore; update the rendering to replace all underscores by using
either decision.status.replaceAll("_", " ") or decision.status.replace(/_/g, "
") so statuses like "ready_for_implementation" become "ready for
implementation"; make the same change for the identical use of decision.status
in app/projects/[id]/decisions/page.tsx (the line noted in the review) to ensure
consistency.
In `@app/projects/`[id]/decisions/page.tsx:
- Around line 54-57: The Filter button currently renders without behavior —
update the Button (and related Filter icon) so it is either functional or
clearly disabled: either add an onClick handler that calls a filter action
(e.g., openFilterPanel, toggleFilters, or setShowFilters) and wire that to the
existing filter state/handler in this component, or mark the Button disabled
(disabled prop and aria-disabled) and add a tooltip/title like "Filters coming
soon" so users know it's intentionally inactive; ensure accessibility attributes
are included and use the same Button and Filter symbols shown in the diff.
- Line 120: The status rendering currently uses decision.status.replace("_", "
") which only replaces the first underscore; update the JSX to use
decision.status.replaceAll("_", " ") so all underscores are converted to spaces
(i.e., change the use of String.prototype.replace to replaceAll for the
expression rendering the status).
In `@components/DecisionExportDialog.tsx`:
- Around line 114-143: formatAsCursor can throw if string fields are
null/undefined; update it to use defensive defaults for all optional fields
referenced (e.g., acceptanceCriteria, scope, risks, nonGoals, title, summary)
and for confidenceScore and linkedFeedbackIds. Replace direct uses like
data.acceptanceCriteria.split("\n") with (data.acceptanceCriteria ||
"").split("\n"), use (data.scope || "") etc., use (data.confidenceScore ?? 0)
for the percentage calc, and ensure linkedFeedbackIds is treated as
(data.linkedFeedbackIds || []) when checking length so the function never calls
string/array methods on undefined; keep the same output structure and formatting
in formatAsCursor and reference DecisionExportData for context.
- Around line 352-357: handleExport currently only calls onExport(format,
content) and does nothing if onExport is not provided, so clicking "Download"
produces no file; change handleExport (and the other similar handler around the
later block) to fall back to client-side download: call updatePreview(format) to
get the string content, create a Blob from that content with an appropriate MIME
type (e.g., text/plain or application/json depending on ExportFormat), create an
object URL via URL.createObjectURL, build an <a> element with download attribute
using a sensible filename (e.g., `${decisionName || 'export'}.${format}`),
programmatically click the anchor, then revoke the URL and clean up the element;
keep the existing onExport call (if provided) but still perform the fallback
download when onExport is undefined.
- Around line 362-377: The custom trigger path in DecisionExportDialog opens the
dialog without initializing the preview; when a custom trigger element is
clicked it only calls setIsOpen(true) but does not call
updatePreview(selectedFormat). Modify the onClick handler used for the custom
trigger (the JSX branch that renders {trigger}) to also invoke
updatePreview(selectedFormat) (or call the same helper used by the default
Button) after setting isOpen so the preview is populated immediately when the
dialog opens.
In `@components/FeedbackList.tsx`:
- Around line 468-477: The QuickDecisionDialog currently passes projectId as
items[0]?.projectId || "" which can be wrong if items becomes empty while
selections remain; change it to derive projectId from the actual selected items
(use selectedItems[0]?.projectId ?? "" or compute a const projectId =
selectedItems[0]?.projectId ?? "" above the JSX) and pass that into the
QuickDecisionDialog prop (reference: QuickDecisionDialog component, variables
selectedItems, items, showQuickDecision, selectedIds).
- Around line 75-81: toggleAll currently compares selectedIds.size to
filtered.length which fails when sizes match but contents differ; change the
logic in toggleAll to check whether every filtered item id is present in
selectedIds (e.g., allSelected = filtered.every(f => selectedIds.has(f.id))) and
use that to decide whether to clear selection or setSelectedIds(new
Set(filtered.map(f => f.id))); apply the same allSelected check to the checkbox
icon condition (the reused derived variable) so the UI reflects actual
membership rather than size comparison.
In `@components/PriorityMatrix.tsx`:
- Line 96: The Tailwind slash opacity modifiers like "text-current/70",
"border-current/20", and "border-current/10" don’t work with the CSS keyword
currentColor; update the affected elements in PriorityMatrix.tsx (e.g., the <p>
showing "No items in this quadrant." and the elements using "border-current/20"
and "border-current/10") to either apply a separate opacity utility (e.g., add
"opacity-70" or "opacity-20"/"opacity-10" on the same element) or replace
currentColor with a concrete color class (e.g., "text-gray-500" and
"border-gray-200") so the intended transparency actually takes effect.
- Line 195: The span element in PriorityMatrix.tsx is using the HTML attribute
"class" which React JSX ignores; change the attribute on the red legend dot (the
span with "w-3 h-3 rounded-full bg-red-500") from class to className so the
Tailwind classes are applied (search for that exact span or the red legend dot
in the PriorityMatrix component and update the attribute).
- Around line 44-52: getQuadrantKey currently treats any non-'low' effort
(including 'medium') as high effort and misclassifies medium-effort items;
update getQuadrantKey(PriorityItem) to explicitly handle 'medium' effort values:
for highImpact, map 'low'→'q1' and 'medium'/'high'→'q2'; for non-highImpact, map
'low'/'medium'→'q3' and 'high'→'q4'. Change the boolean lowEffort check to
direct comparisons against item.effort and add a branch for item.effort ===
'medium' so high-impact medium-effort items go to q2 and medium-impact
medium-effort items go to q3.
In `@components/QuickDecisionDialog.tsx`:
- Around line 375-376: The file currently imports useRouter from
"next/navigation" near the bottom; move the import statement (useRouter from
"next/navigation") into the main import block at the top of
QuickDecisionDialog.tsx alongside the other imports (within the existing import
block around lines 3–28) and then remove the duplicate/bottom import lines (the
import and comment at lines 375–376) so all imports are consolidated at the top.
- Around line 46-60: generateTitle and generateSummary produce awkward text when
selectedFeedback is empty or fields are missing; update both functions
(generateTitle, generateSummary) to guard against empty selectedFeedback and
empty computed arrays: for generateTitle return a sensible default (e.g., "No
feedback selected" or "Review customer feedback") when selectedFeedback.length
=== 0, otherwise compute type/count as before; for generateSummary treat empty
uniqueSources and tiers with fallbacks like "customers" or "unknown source(s)"
and format totalRevenue as $0 when no revenue, ensuring join() is only used when
arrays have items so the final sentence reads naturally even with sparse data.
In `@components/ui/slider.tsx`:
- Line 23: The SliderPrimitive.Thumb contains conflicting border-color classes
(border-slate-200 vs border-slate-900 and dark:border-slate-800 vs
dark:border-slate-50) so remove the unintended duplicates in the className on
SliderPrimitive.Thumb; pick the correct light-mode border (either
border-slate-200 or border-slate-900) and the matching dark-mode border (either
dark:border-slate-800 or dark:border-slate-50), delete the opposite class in
each pair, and ensure the remaining classes preserve the existing rounded, bg,
shadow and focus/disabled utilities.
🧹 Nitpick comments (8)
components/QuickDecisionDialog.tsx (1)
134-153: Custom modal lacks accessibility: no focus trap, no Escape-to-close, norole="dialog".This hand-rolled overlay doesn't trap focus, handle the Escape key, or set
role="dialog"/aria-modal. Screen readers won't announce it as a dialog, and keyboard users can tab behind it. Consider using Radix UI'sDialogprimitive (already a project dependency via other Radix imports) or at minimum addonKeyDownfor Escape and appropriate ARIA attributes.app/projects/[id]/decisions/new/page.tsx (1)
77-89:getConfidenceLabelandgetConfidenceColorare duplicated withQuickDecisionDialog.tsx(Lines 115–127).Both files contain identical implementations. Extract these into a shared utility (e.g.,
lib/confidence.ts) to keep them in sync.app/api/projects/[id]/decisions/[decisionId]/export/route.ts (1)
16-33: Format validation can drift fromExportFormattype.The
validFormatsarray on lines 19–27 duplicates theExportFormatunion fromDecisionExportDialog.tsx. If a format is added/removed in one place but not the other, behavior will diverge silently. Also, theas ExportFormatcast on line 16 happens before validation, so TypeScript won't help catch invalid values.♻️ Derive from the canonical type to keep them in sync
+const VALID_FORMATS: ExportFormat[] = [ + "cursor", "linear", "jira", "slack", "notion", "markdown", "json", +]; + 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)) { + const rawFormat = searchParams.get("format") || "markdown"; + if (!VALID_FORMATS.includes(rawFormat as ExportFormat)) { return NextResponse.json( - { error: `Invalid format. Valid formats: ${validFormats.join(", ")}` }, + { error: `Invalid format. Valid formats: ${VALID_FORMATS.join(", ")}` }, { status: 400 } ); } + const format = rawFormat as ExportFormat;Ideally, export
VALID_FORMATSfromDecisionExportDialog.tsxalongside the type so both the route and the dialog reference the same source of truth.components/FeedbackList.tsx (1)
149-172: Duplicate badge rendering for"analyzed"and"ready_for_implementation"statuses.Both cases return the identical badge (
"Ready for Implementation"). If this is intentional, consider merging them with a fallthrough or a combined condition to make the intent explicit and reduce duplication.♻️ Merge duplicate cases
const getStatusBadge = (status: string) => { switch (status) { case "pending_analysis": return <Badge variant="secondary">Analyzing...</Badge>; case "analyzed": - return ( - <Badge className="bg-blue-500 hover:bg-blue-600"> - Ready for Implementation - </Badge> - ); case "ready_for_implementation": return ( <Badge className="bg-blue-500 hover:bg-blue-600"> Ready for Implementation </Badge> );app/projects/[id]/decisions/[decisionId]/page.tsx (1)
146-161: Read-only checkboxes with nocheckedstate may confuse users.The checkboxes are
readOnlybut always render as unchecked, which looks interactive but isn't. Consider using check-mark icons or a styled list instead to better convey that this is a display-only checklist.♻️ Example: use icons instead of checkboxes
<li key={i} className="flex items-start gap-2"> - <input - type="checkbox" - className="mt-1.5 rounded border-gray-300" - readOnly - /> + <CheckCircle className="w-4 h-4 mt-1 text-gray-300 flex-shrink-0" /> <span className="text-gray-700">{criteria.trim()}</span> </li>app/projects/[id]/decisions/page.tsx (1)
85-97:DecisionExportDataconstruction is duplicated between the list and detail pages.Both this page and
app/projects/[id]/decisions/[decisionId]/page.tsxbuild the sameDecisionExportDataobject from a decision record. Consider extracting a helper (e.g.,toExportData(decision, projectName)) to keep them in sync.app/projects/[id]/analysis/page.tsx (2)
76-90: Unvalidated type assertion onanalysis.content— runtime errors if shape mismatches.The
ascast on the Prisma JSON field assumes a specific structure. If the stored JSON doesn't match (e.g., older analysis format, missing fields), the page will crash with an uncaught property access error.Consider adding defensive access or basic validation, e.g.:
const analysisContent = analysis.content as Record<string, any>; const feedbackCount = analysisContent?.feedbackCount ?? 0; const summary = analysisContent?.summary ?? { bugs: 0, features: 0, other: 0 }; const opportunities = Array.isArray(analysisContent?.opportunities) ? analysisContent.opportunities : []; const unclusteredCount = analysisContent?.unclusteredCount ?? 0; const generatedAt = analysisContent?.generatedAt ?? analysis.createdAt;
248-253: "Create Decision" link doesn't pass opportunity context.Each opportunity's "Create Decision" button links to
/decisions/newwithout any query parameters (e.g., title, description, or opportunity ID). Users must manually re-enter the information that's already displayed. Consider passing context via query params.♻️ Example: pass context via search params
- <Button size="sm" asChild> - <Link href={`/projects/${projectId}/decisions/new`}> + <Button size="sm" asChild> + <Link href={`/projects/${projectId}/decisions/new?title=${encodeURIComponent(opp.title)}&summary=${encodeURIComponent(opp.description)}`}> <Target className="w-4 h-4 mr-1" /> Create Decision </Link> </Button>
| 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 } | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if there's auth middleware protecting API routes
fd "middleware" --type f --extension ts --extension js
echo "---AUTH_PATTERNS---"
# Check for auth patterns in existing API routes
rg -n "getServerSession|auth|currentUser|requireAuth" --type ts -g 'app/api/**'
echo "---NEXTAUTH_CONFIG---"
# Check for NextAuth or auth configuration
fd -e "route.ts" -e "route.js" -e "auth.ts" -e "auth.js" | head -20Repository: Fresh-Industries/pm-analyzer
Length of output: 566
🏁 Script executed:
#!/bin/bash
# Search for middleware at root level
echo "---MIDDLEWARE_FILES---"
find . -maxdepth 2 -name "middleware.*" -type f
echo "---EXPORT_ROUTE_CONTENT---"
# Check the export route for any auth imports
head -15 app/api/projects/\[id\]/decisions/\[decisionId\]/export/route.ts
echo "---AUTH_LIB_FILES---"
# Check auth library files
ls -la lib/auth* 2>/dev/null || echo "No auth files in lib/"
echo "---OTHER_API_ROUTES---"
# Check a few other API routes to see auth patterns
find app/api -name "route.ts" -type f | head -5 | xargs -I {} sh -c 'echo "=== {} ===" && head -20 {}'Repository: Fresh-Industries/pm-analyzer
Length of output: 3688
🏁 Script executed:
#!/bin/bash
# Check if any routes import auth utilities
echo "---ROUTES_USING_AUTH---"
rg -l "from.*auth|import.*auth" app/api --type ts | grep -v "\[...all\]" | head -10
echo "---CHECK_AUTH_SERVER_USAGE---"
rg "getSession|auth\(\)" app/api --type ts | head -20
echo "---LIB_AUTH_FILES_CONTENT---"
echo "=== lib/auth.ts ==="
cat lib/auth.ts
echo "=== lib/auth-server.ts ==="
cat lib/auth-server.tsRepository: Fresh-Industries/pm-analyzer
Length of output: 866
🏁 Script executed:
#!/bin/bash
# Check projects route in detail since it imports auth-client
echo "---PROJECTS_ROUTE_CONTENT---"
cat app/api/projects/route.ts
echo "---CHECK_IF_OTHER_ROUTES_USE_AUTH_CHECKS---"
rg "getServerSession|getSession" app/api --type ts -A 3 | head -30Repository: Fresh-Industries/pm-analyzer
Length of output: 1470
Add authentication check to the export endpoint.
This route lacks user authentication and is publicly accessible to anyone who can guess a valid projectId and decisionId. The current project-ownership check (decision.projectId === projectId) verifies data consistency but does not authenticate the requesting user. Although the app has a getServerSession() function available in lib/auth-server.ts via better-auth, it is not used here. Add a session check to verify the user is authenticated and authorized to access this decision, similar to what should be done across other API routes that currently lack authentication.
🤖 Prompt for AI Agents
In `@app/api/projects/`[id]/decisions/[decisionId]/export/route.ts around lines 9
- 103, Add an authentication and authorization check at the start of the
exported GET handler: call getServerSession() from lib/auth-server.ts and if it
returns no session return a 401 JSON response; then verify the signed-in user is
allowed to access the decision by ensuring they belong to the project (e.g.
query the project using prisma (prisma.project.findFirst or findUnique with
membership/owner check) and confirm project.id === projectId and that
session.user.id is an owner/member), and only proceed to load the Decision (or
return 403) if that authorization check passes; keep the existing
decision.projectId === projectId validation as a secondary data check.
| const filename = `decision-${decision.title | ||
| .toLowerCase() | ||
| .replace(/\s+/g, "-")}.${format === "markdown" ? "md" : format}`; |
There was a problem hiding this comment.
Insufficient filename sanitization — special characters in title can break Content-Disposition header.
decision.title may contain characters like ", \, /, or non-ASCII that the whitespace-only regex won't strip. This can produce a malformed or exploitable Content-Disposition header.
🛡️ Proposed fix: sanitize the title more thoroughly
- 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 extension = format === "markdown" ? "md" : "txt";
+ const filename = `decision-${safeTitle}.${extension}`;Note: the current extension logic also produces non-standard extensions like .cursor, .linear, etc. Consider using .txt for all non-markdown text formats.
📝 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}`; | |
| const safeTitle = decision.title | |
| .toLowerCase() | |
| .replace(/[^a-z0-9]+/g, "-") | |
| .replace(/^-|-$/g, "") | |
| .slice(0, 100); | |
| const extension = format === "markdown" ? "md" : "txt"; | |
| const filename = `decision-${safeTitle}.${extension}`; |
🤖 Prompt for AI Agents
In `@app/api/projects/`[id]/decisions/[decisionId]/export/route.ts around lines 86
- 88, The generated filename built in the filename constant uses raw
decision.title and only strips whitespace, which can introduce quotes, slashes,
backslashes, and other characters that break the Content-Disposition header;
update the logic that builds filename (the filename const) to: sanitize
decision.title by removing or replacing all characters except ASCII letters,
numbers, hyphen and underscore (collapse runs and trim, and cap length to e.g.
100 chars), percent-encode or provide a RFC5987-safe fallback when setting
headers, and normalize extensions so format === "markdown" => ".md" and all
other formats use ".txt" (avoid producing non-standard extensions like
.cursor/.linear). Ensure you reference and change the filename construction and
the code that sets the Content-Disposition header to use the sanitized/encoded
filename.
| risks: risks || "", | ||
| nonGoals: nonGoals || "", | ||
| confidence: confidence || "medium", | ||
| confidenceScore: confidence || 0.5, |
There was a problem hiding this comment.
confidence || 0.5 treats 0 as falsy — use nullish coalescing.
If a caller sends confidence: 0 (a valid value representing 0%), the || operator will discard it and default to 0.5. Use ?? to only default 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 code sets
confidenceScore using the falsy-check operator (confidence || 0.5), which treats
0 as falsy; update the assignment to use nullish coalescing so only
null/undefined fall back to 0.5 (replace the use of || with ?? for the
confidence -> confidenceScore mapping), referencing the confidence variable and
confidenceScore field in the route handler that builds the decision payload.
| 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.
effort is incorrectly derived from impactScore, making the Priority Matrix useless.
Line 97 computes effort directly from impactScore: high impact → high effort, low impact → low effort. This means items can never be "Quick Wins" (high impact, low effort) or "Thankless Tasks" (low impact, high effort) — two of the four quadrants in the Eisenhower matrix will always be empty.
Effort should either come from the analysis data (if available) or be independently estimated.
🐛 Suggested approach: source effort from analysis data or default to medium
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',
+ effort: (opp as any).effort as 'high' | 'medium' | 'low' ?? 'medium',
impactScore: opp.impactScore,
feedbackCount: opp.feedbackCount,
category: opp.category,
}));If the analysis payload doesn't include an effort field yet, consider adding it to the analysis generation step, or default to 'medium' until it's available — rather than coupling effort to impact.
📝 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 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, | |
| })); | |
| 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 as any).effort as 'high' | 'medium' | 'low' ?? 'medium', | |
| impactScore: opp.impactScore, | |
| feedbackCount: opp.feedbackCount, | |
| category: opp.category, | |
| })); |
🤖 Prompt for AI Agents
In `@app/projects/`[id]/analysis/page.tsx around lines 92 - 101, The mapping for
opportunities incorrectly derives effort from impactScore in the
opportunities.map block (constructing OpportunityItem), which prevents valid
Priority Matrix quadrants; change the logic to source effort from the analysis
payload (use opp.effort if present) and only fallback to a neutral default like
'medium' (or an independent estimator) instead of using opp.impactScore; update
the mapping in the same function where opportunities are created and ensure the
OpportunityItem type/shape (and any downstream UI expecting effort) supports
this field.
| </div> | ||
|
|
||
| {/* Summary Stats */} | ||
| <div className="grid grid-cols-4 gap-4"> |
There was a problem hiding this comment.
4-column grid is not responsive — will be cramped on small screens.
grid-cols-4 forces 4 columns at all viewport sizes.
♻️ Add responsive breakpoints
- <div className="grid grid-cols-4 gap-4">
+ <div className="grid grid-cols-2 md: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"> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> |
🤖 Prompt for AI Agents
In `@app/projects/`[id]/analysis/page.tsx at line 133, The grid container
currently uses a fixed "grid grid-cols-4 gap-4" which forces four columns on all
viewports; update the className on that div (the element with "grid grid-cols-4
gap-4") to use responsive Tailwind breakpoints (for example: single column on
xs, then 2 cols on small, 3 on medium, 4 on large) so it collapses on small
screens and expands on larger ones.
| <div className="space-y-2 flex-grow overflow-y-auto max-h-[200px] pr-2"> | ||
| {data.length === 0 ? ( | ||
| <p className="text-xs italic text-current/80">No items categorized here.</p> | ||
| <p className="text-xs italic text-current/70">No items in this quadrant.</p> |
There was a problem hiding this comment.
text-current/70 opacity modifier doesn't work with currentColor.
Tailwind's slash-based opacity syntax (e.g., text-red-500/70) works with color values that can be decomposed into channels, but currentColor is a CSS keyword that cannot be modified this way. The same applies to border-current/20 (Line 101) and border-current/10 (Line 119). These will likely be ignored or produce unexpected output.
Consider using opacity-70 on the element or a specific color class instead.
🤖 Prompt for AI Agents
In `@components/PriorityMatrix.tsx` at line 96, The Tailwind slash opacity
modifiers like "text-current/70", "border-current/20", and "border-current/10"
don’t work with the CSS keyword currentColor; update the affected elements in
PriorityMatrix.tsx (e.g., the <p> showing "No items in this quadrant." and the
elements using "border-current/20" and "border-current/10") to either apply a
separate opacity utility (e.g., add "opacity-70" or "opacity-20"/"opacity-10" on
the same element) or replace currentColor with a concrete color class (e.g.,
"text-gray-500" and "border-gray-200") so the intended transparency actually
takes effect.
| 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. React will emit a warning and the classes won't be applied to this element, leaving the red legend dot unstyled.
🐛 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, The span element in
PriorityMatrix.tsx is using the HTML attribute "class" which React JSX ignores;
change the attribute on the red legend dot (the span with "w-3 h-3 rounded-full
bg-red-500") from class to className so the Tailwind classes are applied (search
for that exact span or the red legend dot in the PriorityMatrix component and
update the attribute).
| 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)`; | ||
| }; |
There was a problem hiding this comment.
Auto-generated text degrades when feedback data is sparse.
generateTitle(Line 46): ifselectedFeedbackis empty, produces "Implement 0 items from customer feedback."generateSummary(Line 54): if all sources are null,uniqueSources.join(", ")is empty, producing "…from (potential impact…". Similarly, emptytiers.
Consider guarding against empty/missing data:
♻️ Suggested improvement
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)`;
+ const sourceText = uniqueSources.length > 0 ? ` from ${uniqueSources.join(", ")}` : "";
+ const tierText = tiers.length > 0 ? ` from ${tiers.join(", ")} customers` : "";
+ return `Address ${selectedFeedback.length} feedback item${selectedFeedback.length > 1 ? "s" : ""}${sourceText} (potential impact: $${totalRevenue.toLocaleString()}/mo${tierText})`;
};📝 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 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)`; | |
| }; | |
| 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))]; | |
| const sourceText = uniqueSources.length > 0 ? ` from ${uniqueSources.join(", ")}` : ""; | |
| const tierText = tiers.length > 0 ? ` from ${tiers.join(", ")} customers` : ""; | |
| return `Address ${selectedFeedback.length} feedback item${selectedFeedback.length > 1 ? "s" : ""}${sourceText} (potential impact: $${totalRevenue.toLocaleString()}/mo${tierText})`; | |
| }; |
🤖 Prompt for AI Agents
In `@components/QuickDecisionDialog.tsx` around lines 46 - 60, generateTitle and
generateSummary produce awkward text when selectedFeedback is empty or fields
are missing; update both functions (generateTitle, generateSummary) to guard
against empty selectedFeedback and empty computed arrays: for generateTitle
return a sensible default (e.g., "No feedback selected" or "Review customer
feedback") when selectedFeedback.length === 0, otherwise compute type/count as
before; for generateSummary treat empty uniqueSources and tiers with fallbacks
like "customers" or "unknown source(s)" and format totalRevenue as $0 when no
revenue, ensuring join() is only used when arrays have items so the final
sentence reads naturally even with sparse data.
| // Import useRouter at top | ||
| import { useRouter } from "next/navigation"; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Move the useRouter import to the top of the file with the other imports.
ESM hoisting makes this work at runtime, but placing an import at the bottom of the file (with a comment saying "Import useRouter at top") is confusing and contradicts the stated intent. Move it to the import block at Lines 3–28.
♻️ Proposed fix
Add to the existing import block (e.g., after Line 3):
import { useState } from "react";
+import { useRouter } from "next/navigation";
import Link from "next/link";Then remove Lines 375–376.
🤖 Prompt for AI Agents
In `@components/QuickDecisionDialog.tsx` around lines 375 - 376, The file
currently imports useRouter from "next/navigation" near the bottom; move the
import statement (useRouter from "next/navigation") into the main import block
at the top of QuickDecisionDialog.tsx alongside the other imports (within the
existing import block around lines 3–28) and then remove the duplicate/bottom
import lines (the import and comment at lines 375–376) so all imports are
consolidated at the top.
| <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.
border-slate-200 border-slate-900 — the last one wins, making the first dead code. Same for dark:border-slate-800 dark:border-slate-50. Likely one of each pair should be removed.
♻️ Proposed fix (keep the intended color)
- <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 SliderPrimitive.Thumb contains
conflicting border-color classes (border-slate-200 vs border-slate-900 and
dark:border-slate-800 vs dark:border-slate-50) so remove the unintended
duplicates in the className on SliderPrimitive.Thumb; pick the correct
light-mode border (either border-slate-200 or border-slate-900) and the matching
dark-mode border (either dark:border-slate-800 or dark:border-slate-50), delete
the opposite class in each pair, and ensure the remaining classes preserve the
existing rounded, bg, shadow and focus/disabled utilities.
What I Built
Added a Quick Decision Builder feature that streamlines the workflow from feedback to decisions.
New Components
QuickDecisionDialog.tsx- A modal dialog for creating decisions from selected feedback itemsFeedbackList.tsx- Enhanced with bulk selectionKey Features
How to Test
Why It Matters
This feature bridges the gap between feedback analysis and decisions. Instead of manually copying feedback IDs and content, users can:
Preview:
📊 Revenue Impact: Now visible at selection time, not just in analysis
🎯 Workflow: Select → Create Decision → Done (3 clicks vs 15+)
Summary by CodeRabbit
Release Notes