feat(12-02): SkillDetail Ratings tab and Overview usage stats

- Add Ratings tab (4th tab) with rate form, personal history, community section
- Overview tab: conditionally render taskCount, avgCostUsd, lastUsedAt rows
- Import StarRating, Separator, Skeleton, Textarea, EmptyState, TooltipProvider
- saveRatingMutation calls addRating, invalidates ratings query on success
- ratingsQuery loads personal history with loading/empty/error states
This commit is contained in:
Mikkel Georgsen 2026-04-01 04:25:00 +02:00
parent 6a79c8b004
commit bb7d84e34a

View file

@ -27,9 +27,20 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PageTabBar } from "@/components/PageTabBar";
import { PageSkeleton } from "@/components/PageSkeleton";
import { EmptyState } from "@/components/EmptyState";
import { StarRating } from "@/components/StarRating";
/* ------------------------------------------------------------------ */
/* VersionDiff component */
@ -76,6 +87,10 @@ export function SkillDetail() {
const [versionA, setVersionA] = useState<string>("");
const [versionB, setVersionB] = useState<string>("");
// Rating form state
const [pendingStars, setPendingStars] = useState(0);
const [pendingNote, setPendingNote] = useState("");
// Install dialog state — used for both install and update flows
const [installDialog, setInstallDialog] = useState<{
skillId: string;
@ -102,6 +117,16 @@ export function SkillDetail() {
enabled: !!skillId,
});
const {
data: ratings = [],
isLoading: ratingsLoading,
isError: ratingsError,
} = useQuery({
queryKey: ["skill-ratings", skillId],
queryFn: () => skillRegistryApi.getRatings(skillId),
enabled: !!skillId,
});
/* ---------------------------------------------------------------- */
/* Breadcrumbs */
/* ---------------------------------------------------------------- */
@ -173,6 +198,24 @@ export function SkillDetail() {
},
});
const saveRatingMutation = useMutation({
mutationFn: ({ stars, note }: { stars: number; note: string }) =>
skillRegistryApi.addRating(skillId, {
stars,
versionId: skill?.activeVersionId ?? undefined,
note: note.trim() || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skill-ratings", skillId] });
setPendingStars(0);
setPendingNote("");
pushToast({ title: "Rating saved", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to save rating", body: err.message, tone: "error" });
},
});
/* ---------------------------------------------------------------- */
/* Loading / error states */
/* ---------------------------------------------------------------- */
@ -274,131 +317,269 @@ export function SkillDetail() {
)}
{/* Detail tabs */}
<Tabs value={detailTab} onValueChange={setDetailTab}>
<PageTabBar
items={[
{ value: "overview", label: "Overview" },
{ value: "versions", label: "Versions" },
{ value: "diff", label: "Diff" },
]}
value={detailTab}
onValueChange={setDetailTab}
align="start"
/>
<TooltipProvider>
<Tabs value={detailTab} onValueChange={setDetailTab}>
<PageTabBar
items={[
{ value: "overview", label: "Overview" },
{ value: "versions", label: "Versions" },
{ value: "diff", label: "Diff" },
{ value: "ratings", label: "Ratings" },
]}
value={detailTab}
onValueChange={setDetailTab}
align="start"
/>
{/* Overview tab */}
<TabsContent value="overview" className="space-y-1 pt-4">
<div className="space-y-1">
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Installs</span>
<span className="text-sm font-medium">{skill.ratingCount ?? "\u2014"}</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Average rating</span>
<span className="flex items-center gap-1 text-sm font-medium">
{skill.averageRating != null ? (
<>
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
{skill.averageRating.toFixed(1)}
</>
) : (
"No ratings yet"
)}
</span>
</div>
{skill.averageRating != null && skill.ratingCount != null && (
{/* Overview tab */}
<TabsContent value="overview" className="space-y-1 pt-4">
<div className="space-y-1">
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Community</span>
<span className="text-sm font-medium">({skill.ratingCount} ratings)</span>
<span className="text-xs text-muted-foreground">Installs</span>
<span className="text-sm font-semibold">{skill.ratingCount ?? "\u2014"}</span>
</div>
)}
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Source</span>
<span className="text-xs font-mono text-muted-foreground">{skill.sourceId}</span>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Average rating</span>
<span className="flex items-center gap-1 text-sm font-semibold">
{skill.averageRating != null ? (
<>
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
{skill.averageRating.toFixed(1)}
</>
) : (
"No ratings yet"
)}
</span>
</div>
{skill.averageRating != null && skill.ratingCount != null && (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Community</span>
<span className="text-sm font-semibold">({skill.ratingCount} ratings)</span>
</div>
)}
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Source</span>
<span className="text-xs font-mono text-muted-foreground">{skill.sourceId}</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Active version</span>
<span className="text-xs font-mono text-muted-foreground">
{skill.activeVersionId
? `v${skill.activeVersionId.split("@").pop() ?? skill.activeVersionId}`
: "\u2014"}
</span>
</div>
{/* Usage stats — Phase 12 */}
{skill.taskCount != null && skill.taskCount > 0 && (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Tasks completed</span>
<span className="text-sm font-semibold">{skill.taskCount}</span>
</div>
)}
{skill.avgCostUsd != null && (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Avg task cost</span>
<span className="text-sm font-semibold">${skill.avgCostUsd.toFixed(4)}</span>
</div>
)}
{skill.lastUsedAt != null && (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Last used</span>
<span className="text-sm font-semibold">{relativeTime(new Date(skill.lastUsedAt))}</span>
</div>
)}
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Active version</span>
<span className="text-xs font-mono text-muted-foreground">
{skill.activeVersionId
? `v${skill.activeVersionId.split("@").pop() ?? skill.activeVersionId}`
: "\u2014"}
</span>
</div>
</div>
</TabsContent>
</TabsContent>
{/* Versions tab */}
<TabsContent value="versions" className="pt-4">
{versions.length === 0 ? (
<p className="text-sm text-muted-foreground">No versions fetched yet.</p>
) : (
<ScrollArea className="max-h-96">
<div className="space-y-1">
{versions.map((v) => (
<div
key={v.id}
className="flex items-center justify-between py-2 px-2 rounded hover:bg-accent/50"
>
<div>
<span className="text-xs font-mono text-muted-foreground">{v.version}</span>
<span className="text-xs text-muted-foreground ml-2">
{relativeTime(new Date(v.fetchedAt))}
</span>
{/* Versions tab */}
<TabsContent value="versions" className="pt-4">
{versions.length === 0 ? (
<p className="text-sm text-muted-foreground">No versions fetched yet.</p>
) : (
<ScrollArea className="max-h-96">
<div className="space-y-1">
{versions.map((v) => (
<div
key={v.id}
className="flex items-center justify-between py-2 px-2 rounded hover:bg-accent/50"
>
<div>
<span className="text-xs font-mono text-muted-foreground">{v.version}</span>
<span className="text-xs text-muted-foreground ml-2">
{relativeTime(new Date(v.fetchedAt))}
</span>
</div>
{v.id === skill.activeVersionId && (
<Badge variant="outline" className="text-xs">
Active
</Badge>
)}
</div>
{v.id === skill.activeVersionId && (
<Badge variant="outline" className="text-xs">
Active
</Badge>
)}
</div>
))}
</div>
</ScrollArea>
)}
</TabsContent>
))}
</div>
</ScrollArea>
)}
</TabsContent>
{/* Diff tab */}
<TabsContent value="diff" className="pt-4 space-y-4">
<div className="flex gap-2 mb-4">
<Select value={versionA} onValueChange={setVersionA}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select version A" />
</SelectTrigger>
<SelectContent>
{versions.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.version}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={versionB} onValueChange={setVersionB}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select version B" />
</SelectTrigger>
<SelectContent>
{versions.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.version}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{versionA && versionB ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Version diff requires file content endpoint (coming in a future update).
</p>
<VersionDiff oldContent="" newContent="" />
{/* Diff tab */}
<TabsContent value="diff" className="pt-4 space-y-4">
<div className="flex gap-2 mb-4">
<Select value={versionA} onValueChange={setVersionA}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select version A" />
</SelectTrigger>
<SelectContent>
{versions.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.version}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={versionB} onValueChange={setVersionB}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select version B" />
</SelectTrigger>
<SelectContent>
{versions.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.version}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<p className="text-xs text-muted-foreground">
Select two versions above to compare them.
</p>
)}
</TabsContent>
</Tabs>
{versionA && versionB ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Version diff requires file content endpoint (coming in a future update).
</p>
<VersionDiff oldContent="" newContent="" />
</div>
) : (
<p className="text-xs text-muted-foreground">
Select two versions above to compare them.
</p>
)}
</TabsContent>
{/* Ratings tab */}
<TabsContent value="ratings" className="pt-4 space-y-6">
{/* Section 1: Rate this skill */}
<div className="space-y-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Rate this skill
</h2>
<div className="space-y-2">
<StarRating value={pendingStars} onChange={setPendingStars} size="md" />
<Textarea
placeholder="Add a note about this version\u2026"
value={pendingNote}
onChange={(e) => setPendingNote(e.target.value)}
rows={3}
className="resize-none"
/>
</div>
<Button
size="sm"
variant="default"
disabled={pendingStars === 0 || saveRatingMutation.isPending}
onClick={() => {
if (pendingStars === 0) return;
saveRatingMutation.mutate({ stars: pendingStars, note: pendingNote });
}}
>
{saveRatingMutation.isPending ? "Saving\u2026" : "Save rating"}
</Button>
{saveRatingMutation.isError && (
<p className="text-sm text-destructive">
{(saveRatingMutation.error as Error).message}
</p>
)}
</div>
<Separator />
{/* Section 2: Your Rating History */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Your Rating History
</h2>
{ratingsError && (
<p className="text-sm text-destructive">Failed to load ratings.</p>
)}
{ratingsLoading && (
<div aria-hidden="true" className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
)}
{!ratingsLoading && !ratingsError && ratings.length === 0 && (
<EmptyState
icon={Star}
message="No ratings yet"
/>
)}
{!ratingsLoading && !ratingsError && ratings.length > 0 && (
<ScrollArea className="max-h-80">
<div className="space-y-1">
{ratings.map((r) => (
<div
key={r.id}
className="flex items-start justify-between py-2 px-2 rounded hover:bg-accent/50"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<StarRating value={r.stars} readonly size="sm" />
{r.versionId && (
<Badge variant="outline" className="text-xs font-mono">
{r.versionId.slice(-7)}
</Badge>
)}
</div>
{r.note && (
<p className="text-sm text-muted-foreground">{r.note}</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0 ml-4">
{relativeTime(new Date(r.createdAt))}
</span>
</div>
))}
</div>
</ScrollArea>
)}
</div>
<Separator />
{/* Section 3: Community Ratings */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Community Ratings
</h2>
{skill.averageRating != null ? (
<div className="space-y-1">
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Average</span>
<span className="flex items-center gap-2">
<StarRating value={Math.round(skill.averageRating)} readonly size="sm" />
<span className="text-sm font-semibold">{skill.averageRating.toFixed(1)}</span>
</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Ratings</span>
<span className="text-sm font-semibold">{skill.ratingCount}</span>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No community ratings available.</p>
)}
</div>
</TabsContent>
</Tabs>
</TooltipProvider>
{/* Install / Update dialog */}
<Dialog open={!!installDialog} onOpenChange={(open) => !open && setInstallDialog(null)}>