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 committed by Nexus Dev
parent bf52c56f5a
commit bbcbc86035

View file

@ -27,9 +27,20 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent } from "@/components/ui/tabs"; 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 { PageTabBar } from "@/components/PageTabBar";
import { PageSkeleton } from "@/components/PageSkeleton"; import { PageSkeleton } from "@/components/PageSkeleton";
import { EmptyState } from "@/components/EmptyState";
import { StarRating } from "@/components/StarRating";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* VersionDiff component */ /* VersionDiff component */
@ -76,6 +87,10 @@ export function SkillDetail() {
const [versionA, setVersionA] = useState<string>(""); const [versionA, setVersionA] = useState<string>("");
const [versionB, setVersionB] = 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 // Install dialog state — used for both install and update flows
const [installDialog, setInstallDialog] = useState<{ const [installDialog, setInstallDialog] = useState<{
skillId: string; skillId: string;
@ -102,6 +117,16 @@ export function SkillDetail() {
enabled: !!skillId, enabled: !!skillId,
}); });
const {
data: ratings = [],
isLoading: ratingsLoading,
isError: ratingsError,
} = useQuery({
queryKey: ["skill-ratings", skillId],
queryFn: () => skillRegistryApi.getRatings(skillId),
enabled: !!skillId,
});
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Breadcrumbs */ /* 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 */ /* Loading / error states */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
@ -274,12 +317,14 @@ export function SkillDetail() {
)} )}
{/* Detail tabs */} {/* Detail tabs */}
<TooltipProvider>
<Tabs value={detailTab} onValueChange={setDetailTab}> <Tabs value={detailTab} onValueChange={setDetailTab}>
<PageTabBar <PageTabBar
items={[ items={[
{ value: "overview", label: "Overview" }, { value: "overview", label: "Overview" },
{ value: "versions", label: "Versions" }, { value: "versions", label: "Versions" },
{ value: "diff", label: "Diff" }, { value: "diff", label: "Diff" },
{ value: "ratings", label: "Ratings" },
]} ]}
value={detailTab} value={detailTab}
onValueChange={setDetailTab} onValueChange={setDetailTab}
@ -291,11 +336,11 @@ export function SkillDetail() {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Installs</span> <span className="text-xs text-muted-foreground">Installs</span>
<span className="text-sm font-medium">{skill.ratingCount ?? "\u2014"}</span> <span className="text-sm font-semibold">{skill.ratingCount ?? "\u2014"}</span>
</div> </div>
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Average rating</span> <span className="text-xs text-muted-foreground">Average rating</span>
<span className="flex items-center gap-1 text-sm font-medium"> <span className="flex items-center gap-1 text-sm font-semibold">
{skill.averageRating != null ? ( {skill.averageRating != null ? (
<> <>
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" /> <Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
@ -309,7 +354,7 @@ export function SkillDetail() {
{skill.averageRating != null && skill.ratingCount != null && ( {skill.averageRating != null && skill.ratingCount != null && (
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Community</span> <span className="text-xs text-muted-foreground">Community</span>
<span className="text-sm font-medium">({skill.ratingCount} ratings)</span> <span className="text-sm font-semibold">({skill.ratingCount} ratings)</span>
</div> </div>
)} )}
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
@ -324,6 +369,25 @@ export function SkillDetail() {
: "\u2014"} : "\u2014"}
</span> </span>
</div> </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>
</TabsContent> </TabsContent>
@ -398,7 +462,124 @@ export function SkillDetail() {
</p> </p>
)} )}
</TabsContent> </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> </Tabs>
</TooltipProvider>
{/* Install / Update dialog */} {/* Install / Update dialog */}
<Dialog open={!!installDialog} onOpenChange={(open) => !open && setInstallDialog(null)}> <Dialog open={!!installDialog} onOpenChange={(open) => !open && setInstallDialog(null)}>