feat: automatic recommendations + manual evaluation in redundancy analysis

This commit is contained in:
root
2026-07-29 23:51:28 +02:00
parent 44cc29b8fe
commit 857d2e11a8
3 changed files with 226 additions and 60 deletions
+109 -53
View File
@@ -1,16 +1,19 @@
import { useState, useEffect } from "react";
import { Layout } from "@/components/layout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Progress } from "@/components/ui/progress";
import { Star, AlertTriangle, DollarSign } from "lucide-react";
import { Star, AlertTriangle, DollarSign, ThumbsUp, RotateCcw } from "lucide-react";
import { Link } from "wouter";
import { customFetch } from "@workspace/api-client-react";
import { useToast } from "@/hooks/use-toast";
export default function RedundancyPage() {
const [data, setData] = useState<any[] | null>(null);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
customFetch<any[]>("/api/admin/redundancy")
@@ -19,27 +22,47 @@ export default function RedundancyPage() {
.finally(() => setLoading(false));
}, []);
async function handleEvaluate(toolId: number, relatedToolId: number, betterToolId: number) {
try {
await customFetch("/api/admin/redundancy/evaluate", {
method: "POST",
body: JSON.stringify({ toolId, relatedToolId, betterToolId }),
});
toast({ title: "Evaluation saved" });
setData(await customFetch<any[]>("/api/admin/redundancy"));
} catch {
toast({ title: "Failed to save evaluation", variant: "destructive" });
}
}
function certaintyColor(c: string) {
return c === "high" ? "bg-green-100 text-green-800 border-green-300" : c === "medium" ? "bg-amber-100 text-amber-800 border-amber-300" : "bg-gray-100 text-gray-600 border-gray-300";
}
return (
<Layout>
<div className="space-y-6">
<div className="flex items-center gap-3">
<AlertTriangle className="w-6 h-6 text-amber-500" />
<h1 className="text-3xl font-bold">Redundancy Dashboard</h1>
<h1 className="text-3xl font-bold">Tool Analysis & Recommendations</h1>
</div>
<p className="text-muted-foreground">
Tools grouped by category with feature overlap analysis.
Automatic redundancy detection with cost and rating comparison. Admin can manually confirm which tool is the better choice.
</p>
{loading ? (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-40 rounded-xl" />)}
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-48 rounded-xl" />)}
</div>
) : data && data.length > 0 ? (
<div className="space-y-8">
{data.map((group) => (
<div key={group.category}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold capitalize">{group.category}</h2>
<div>
<h2 className="text-xl font-semibold capitalize">{group.category}</h2>
<p className="text-xs text-muted-foreground mt-0.5">{group.tools.length} tools, {group.pairs.length} comparisons</p>
</div>
{group.totalMonthlyCost > 0 && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<DollarSign className="w-3 h-3" />
@@ -47,14 +70,22 @@ export default function RedundancyPage() {
</span>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
{group.tools.map((tool: any) => (
<Link key={tool.id} href={`/tools/${tool.id}`}>
<Card className="hover-elevate transition-all cursor-pointer hover:border-primary/50">
<Card className="hover-elevate transition-all cursor-pointer hover:border-primary/50 h-full">
<CardContent className="p-4">
<div className="flex items-start gap-2 justify-between">
<div className="min-w-0">
<span className="font-medium">{tool.name}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{tool.name}</span>
{tool.costs?.length > 0 && tool.totalMonthly > 0 && (
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-0">
{tool.totalMonthly.toFixed(2)}/mo
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground flex-wrap">
<span>{tool.ratingCount} reviews</span>
{tool.avgCombined != null && (
@@ -66,25 +97,12 @@ export default function RedundancyPage() {
</span>
</>
)}
{tool.totalMonthly > 0 && (
<>
<span>·</span>
<span className="flex items-center gap-0.5 text-amber-600">
<DollarSign className="w-3 h-3" />
{tool.totalMonthly.toFixed(2)}/mo
</span>
</>
)}
</div>
{tool.costs?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{tool.costs.map((c: any, i: number) => (
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">
{c.licenseType}{c.billingPeriod ? ` (${c.billingPeriod})` : ""}
</Badge>
))}
</div>
)}
<div className="flex flex-wrap gap-1 mt-2">
{tool.costs?.map((c: any, i: number) => (
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">{c.licenseType}{c.billingPeriod ? ` (${c.billingPeriod})` : ""}</Badge>
))}
</div>
</div>
<Badge variant="outline" className="shrink-0">{tool.features.length} features</Badge>
</div>
@@ -95,34 +113,72 @@ export default function RedundancyPage() {
</div>
{group.pairs.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Overlap Analysis</h3>
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">Comparisons & Recommendations</h3>
{group.pairs.map((pair: any, i: number) => (
<Card key={i} className="border-dashed">
<CardContent className="p-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
<span className="font-medium text-sm truncate">{pair.a.name}</span>
<span className="text-muted-foreground text-xs shrink-0">vs</span>
<span className="font-medium text-sm truncate">{pair.b.name}</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="flex items-center gap-2">
<Progress value={pair.overlap} className="w-16 h-2" />
<span className="text-xs text-muted-foreground w-8">{pair.overlap}%</span>
<Card key={i} className={pair.recommendation.certainty === "high" ? "border-green-300" : pair.recommendation.certainty === "medium" ? "border-amber-200" : ""}>
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="text-right min-w-0 flex-1">
<span className={`font-medium text-sm block truncate ${pair.recommendation.betterToolId === pair.a.id ? "text-primary" : ""}`}>
{pair.a.name}
</span>
<span className="text-[10px] text-muted-foreground">
{pair.a.avgCombined != null ? pair.a.avgCombined.toFixed(1) : "N/A"}
{pair.a.totalMonthly > 0 ? ` · ${pair.a.totalMonthly.toFixed(2)}/mo` : ""}
</span>
</div>
<div className="text-center shrink-0">
<div className="text-xs text-muted-foreground font-medium">vs</div>
<div className="flex items-center gap-1 justify-center mt-0.5">
<Progress value={pair.overlap} className="w-12 h-1.5" />
<span className="text-[10px] text-muted-foreground">{pair.overlap}%</span>
</div>
</div>
<div className="min-w-0 flex-1">
<span className={`font-medium text-sm block truncate ${pair.recommendation.betterToolId === pair.b.id ? "text-primary" : ""}`}>
{pair.b.name}
</span>
<span className="text-[10px] text-muted-foreground">
{pair.b.avgCombined != null ? pair.b.avgCombined.toFixed(1) : "N/A"}
{pair.b.totalMonthly > 0 ? ` · ${pair.b.totalMonthly.toFixed(2)}/mo` : ""}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 justify-end">
{pair.recommendation.reason && (
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${certaintyColor(pair.recommendation.certainty)}`}>
<ThumbsUp className="w-2.5 h-2.5 mr-0.5" />
{pair.recommendation.betterName}
</Badge>
)}
<div className="flex gap-1">
<Button
variant={pair.manualBetterId === pair.a.id ? "default" : "ghost"}
size="sm"
className="text-xs h-7 px-2"
onClick={() => handleEvaluate(pair.a.id, pair.b.id, pair.a.id)}
>
{pair.a.name}
</Button>
<Button
variant={pair.manualBetterId === pair.b.id ? "default" : "ghost"}
size="sm"
className="text-xs h-7 px-2"
onClick={() => handleEvaluate(pair.a.id, pair.b.id, pair.b.id)}
>
{pair.b.name}
</Button>
</div>
</div>
{pair.scoreDiff !== 0 && (
<Badge variant={pair.scoreDiff > 0 ? "default" : "secondary"} className="text-[10px]">
{pair.scoreDiff > 0 ? `${pair.b.name} +${pair.scoreDiff.toFixed(1)}` : `${pair.a.name} +${Math.abs(pair.scoreDiff).toFixed(1)}`}
</Badge>
)}
{pair.costDiff !== 0 && (
<span className="text-[10px] text-muted-foreground">
{pair.costDiff > 0
? `${pair.b.name} +${pair.costDiff.toFixed(2)}/mo`
: `${pair.a.name} +${Math.abs(pair.costDiff).toFixed(2)}/mo`}
</span>
)}
</div>
{pair.recommendation.reason && (
<p className="text-[10px] text-muted-foreground mt-2 italic">
{pair.recommendation.reason}
</p>
)}
</CardContent>
</Card>
))}