diff --git a/apps/web/src/components/CompatibilityHeatmap.tsx b/apps/web/src/components/CompatibilityHeatmap.tsx new file mode 100644 index 0000000..6e525fe --- /dev/null +++ b/apps/web/src/components/CompatibilityHeatmap.tsx @@ -0,0 +1,232 @@ +import { useMemo, useState } from "react"; +import type { Tool, Rule, CategoryId } from "@stackfast/schemas"; +import { evaluateRulesSync } from "../engine"; + +export interface CompatibilityHeatmapProps { + tools: Tool[]; + rules: Rule[]; + categories: { id: CategoryId; name: string }[]; +} + +interface HeatmapCell { + rowTool: Tool; + colTool: Tool; + score: number; + hasError: boolean; + hasSynergy: boolean; +} + +const DEFAULT_ROW: CategoryId = "frontend"; +const DEFAULT_COL: CategoryId = "hosting"; + +export function CompatibilityHeatmap({ tools, rules, categories }: CompatibilityHeatmapProps) { + const [rowCategory, setRowCategory] = useState(DEFAULT_ROW); + const [colCategory, setColCategory] = useState(DEFAULT_COL); + + const rowTools = useMemo( + () => tools.filter((tool) => tool.categoryId === rowCategory && !tool.deprecated), + [tools, rowCategory], + ); + const colTools = useMemo( + () => tools.filter((tool) => tool.categoryId === colCategory && !tool.deprecated), + [tools, colCategory], + ); + + const cells = useMemo(() => { + return rowTools.map((rowTool) => + colTools.map((colTool): HeatmapCell => { + if (rowTool.id === colTool.id) { + return { + rowTool, + colTool, + score: 100, + hasError: false, + hasSynergy: false, + }; + } + const evaluation = evaluateRulesSync([rowTool, colTool], rules); + return { + rowTool, + colTool, + score: Math.round(evaluation.score), + hasError: evaluation.diagnostics.some((d) => d.level === "error"), + hasSynergy: evaluation.diagnostics.some((d) => d.category === "synergy"), + }; + }), + ); + }, [rowTools, colTools, rules]); + + const hasData = rowTools.length > 0 && colTools.length > 0; + + return ( +
+
+

Compatibility Matrix

+

+ Pick two categories to see how every tool in one pairs with every tool in the other. Cells + are color-coded by harmony score; red badges mark hard conflicts and green dots mark known + synergies. +

+
+ +
+
+ + +
+
+ + +
+
+ + + + {!hasData ? ( +
+ No active tools found for the selected categories. +
+ ) : ( +
+ + + + + ))} + + + + {cells.map((row, rowIndex) => { + const rowTool = rowTools[rowIndex]; + return ( + + + {row.map((cell) => ( + + ))} + + ); + })} + +
+ {colTools.map((tool) => ( + +
+ {tool.name} +
+
+
+ {rowTool.name} +
+
+
+ )} +
+ ); +} + +function HeatmapLegend() { + return ( +
+ Legend: + + + + + +
+ ); +} + +function LegendSwatch({ label, className }: { label: string; className: string }) { + return ( + + + {label} + + ); +} + +function HeatmapCellView({ cell }: { cell: HeatmapCell }) { + const { score, hasError, hasSynergy, rowTool, colTool } = cell; + const className = scoreToClass(score, hasError); + const title = + rowTool.id === colTool.id + ? `${rowTool.name} (same tool)` + : `${rowTool.name} × ${colTool.name} — score ${score}${hasError ? " (conflict)" : ""}${ + hasSynergy ? " (synergy)" : "" + }`; + + return ( + + {score} + {hasSynergy && !hasError && ( + + )} + {hasError && ( + + )} + + ); +} + +function scoreToClass(score: number, hasError: boolean): string { + if (hasError) return "bg-destructive/30 text-destructive-foreground"; + if (score >= 80) return "bg-emerald-500/30 text-emerald-100"; + if (score >= 60) return "bg-lime-500/25 text-lime-100"; + if (score >= 40) return "bg-amber-500/25 text-amber-100"; + if (score >= 20) return "bg-orange-500/25 text-orange-100"; + return "bg-destructive/25 text-destructive-foreground"; +} diff --git a/apps/web/src/pages/CompatibilityView.tsx b/apps/web/src/pages/CompatibilityView.tsx index 8dd84d5..f3f384f 100644 --- a/apps/web/src/pages/CompatibilityView.tsx +++ b/apps/web/src/pages/CompatibilityView.tsx @@ -1,48 +1,107 @@ import { useState } from "react"; -import { useCompatibility, useTools } from "../hooks/useApi"; -import { Loader2, ArrowRightLeft, ShieldAlert, CheckCircle2, Info } from "lucide-react"; +import { useCatalog, useCompatibility, useTools } from "../hooks/useApi"; +import { + ArrowRightLeft, + CheckCircle2, + Grid3x3, + Info, + Loader2, + ShieldAlert, +} from "lucide-react"; import { Layout } from "../components/Layout"; +import { CompatibilityHeatmap } from "../components/CompatibilityHeatmap"; + +type Mode = "pairwise" | "matrix"; export function CompatibilityView() { + const [mode, setMode] = useState("pairwise"); + + return ( + +
+
+

Compatibility Analyzer

+

+ Inspect how two tools pair together, or survey an entire category pairing at a glance. +

+
+ + + + {mode === "pairwise" ? : } +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Mode switcher +// --------------------------------------------------------------------------- + +function ModeTabs({ mode, onModeChange }: { mode: Mode; onModeChange: (mode: Mode) => void }) { + const tabs: { id: Mode; label: string; icon: typeof ArrowRightLeft }[] = [ + { id: "pairwise", label: "Pairwise", icon: ArrowRightLeft }, + { id: "matrix", label: "Matrix", icon: Grid3x3 }, + ]; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Pairwise analyzer (unchanged behavior) +// --------------------------------------------------------------------------- + +function PairwiseAnalyzer() { const [toolA, setToolA] = useState(""); const [toolB, setToolB] = useState(""); - const { data: toolsData } = useTools({ limit: 100 }); const { data: compatibility, isLoading, error } = useCompatibility(toolA, toolB); - const getDiagnosticIcon = (level: string) => { - switch (level) { - case "error": return ; - case "warning": return ; - case "info": return ; - case "success": return ; - default: return ; - } - }; - - const tools = toolsData?.items || []; + const tools = toolsData?.items ?? []; return ( - -
-
-

Compatibility Analyzer

-

- Select two tools to see how well they play together and uncover potential integration issues. -

-
- +
-
@@ -55,14 +114,16 @@ export function CompatibilityView() {
-
@@ -80,57 +141,131 @@ export function CompatibilityView() { Failed to analyze compatibility. Please try again.
) : compatibility ? ( -
- {/* Score Display */} -
-
= 80 ? 'bg-diagnostic-success' : - compatibility.harmonyScore >= 50 ? 'bg-diagnostic-warning' : 'bg-destructive' - }`} /> - -

Harmony Score

-
= 80 ? 'text-diagnostic-success' : - compatibility.harmonyScore >= 50 ? 'text-diagnostic-warning' : 'text-destructive' - }`}> - {compatibility.harmonyScore} -
-
- {compatibility.harmonyScore >= 80 ? "Excellent compatibility. These tools are often used together." : - compatibility.harmonyScore >= 50 ? "Moderate compatibility. May require custom configuration." : - "Poor compatibility. Significant friction expected."} -
-
+ + ) : null} +
+ )} +
+ ); +} + +function PairwiseResult({ + compatibility, +}: { + compatibility: { + harmonyScore: number; + diagnostics: { level: string; message: string }[]; + }; +}) { + return ( +
+
+
= 80 + ? "bg-diagnostic-success" + : compatibility.harmonyScore >= 50 + ? "bg-diagnostic-warning" + : "bg-destructive" + }`} + /> +

Harmony Score

+
= 80 + ? "text-diagnostic-success" + : compatibility.harmonyScore >= 50 + ? "text-diagnostic-warning" + : "text-destructive" + }`} + > + {compatibility.harmonyScore} +
+
+ {compatibility.harmonyScore >= 80 + ? "Excellent compatibility. These tools are often used together." + : compatibility.harmonyScore >= 50 + ? "Moderate compatibility. May require custom configuration." + : "Poor compatibility. Significant friction expected."} +
+
- {/* Diagnostics List */} -
-

Integration Analysis

-
- {compatibility.diagnostics.length > 0 ? ( - compatibility.diagnostics.map((diag, i) => ( -
-
{getDiagnosticIcon(diag.level)}
-
-

- {diag.level} -

-

{diag.message}

-
-
- )) - ) : ( -
- -

No significant issues detected when combining these tools.

-
- )} +
+

Integration Analysis

+
+ {compatibility.diagnostics.length > 0 ? ( + compatibility.diagnostics.map((diag, i) => ( +
+
{diagnosticIcon(diag.level)}
+
+

+ {diag.level} +

+

{diag.message}

+ )) + ) : ( +
+ +

+ No significant issues detected when combining these tools. +

- ) : null} + )}
- )} +
- + ); +} + +function diagnosticIcon(level: string) { + switch (level) { + case "error": + return ; + case "warning": + return ; + case "info": + return ; + case "success": + return ; + default: + return ; + } +} + +// --------------------------------------------------------------------------- +// Matrix analyzer (new) +// --------------------------------------------------------------------------- + +function MatrixAnalyzer() { + const { data: catalog, isLoading, error } = useCatalog(); + + if (isLoading) { + return ( +
+ +

Loading catalog...

+
+ ); + } + + if (error || !catalog) { + return ( +
+ Failed to load the catalog. Please try refreshing. +
+ ); + } + + return ( + ); } diff --git a/tests/e2e/mvp-flows.spec.ts b/tests/e2e/mvp-flows.spec.ts index 07f9ec2..e3a454c 100644 --- a/tests/e2e/mvp-flows.spec.ts +++ b/tests/e2e/mvp-flows.spec.ts @@ -46,6 +46,28 @@ test.describe("Stackfast MVP flows", () => { await expect(page.getByRole("heading", { name: "Integration Analysis" })).toBeVisible(); }); + test("shows a compatibility matrix across two categories", async ({ page }) => { + await page.goto("/compatibility"); + + await page.getByTestId("compat-tab-matrix").click(); + + const heatmap = page.getByTestId("compatibility-heatmap"); + await expect(heatmap).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("heading", { name: "Compatibility Matrix" })).toBeVisible(); + + await page.getByTestId("heatmap-row-select").selectOption("frontend"); + await page.getByTestId("heatmap-col-select").selectOption("hosting"); + + const table = page.getByTestId("heatmap-table"); + await expect(table).toBeVisible(); + // At least one cell should render a harmony score. The initial load has + // frontend × hosting so we expect Next.js × Vercel to be excellent. + await expect(table.locator("td[data-score]").first()).toBeVisible(); + await expect(table).toContainText("Next.js"); + await expect(table).toContainText("Vercel"); + await page.screenshot({ path: "test-results/compat-matrix.png", fullPage: true }); + }); + test("shows a basic migration path", async ({ page }) => { await page.goto("/migration");