diff --git a/artifacts/api-server/src/middleware/feature.ts b/artifacts/api-server/src/middleware/feature.ts index 39e6188..b48282a 100644 --- a/artifacts/api-server/src/middleware/feature.ts +++ b/artifacts/api-server/src/middleware/feature.ts @@ -2,8 +2,8 @@ import { type Request, type Response, type NextFunction } from "express"; const TIER_FEATURES: Record = { free: ["browse", "rate", "search"], - premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash"], - enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "sso", "audit-export", "api-access"], + premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist"], + enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "sso", "audit-export", "api-access"], }; export function getEntitlements(tier: string | undefined, role: string | undefined): string[] { diff --git a/artifacts/api-server/src/routes/tools.ts b/artifacts/api-server/src/routes/tools.ts index daf4111..18fad7c 100644 --- a/artifacts/api-server/src/routes/tools.ts +++ b/artifacts/api-server/src/routes/tools.ts @@ -1,5 +1,5 @@ import { Router, type IRouter } from "express"; -import { eq, desc, sql, and, not, isNull, inArray } from "drizzle-orm"; +import { eq, desc, asc, sql, and, not, isNull, inArray } from "drizzle-orm"; import { z } from "zod"; import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db"; import { @@ -232,6 +232,65 @@ router.get("/tools/:id", async (req, res): Promise => { res.json(buildToolWithStats(tool, ratings)); }); +router.get("/tools/:id/rating-history", async (req, res): Promise => { + const params = GetToolParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + const ratings = await db + .select({ + date: ratingsTable.createdAt, + usefulness: ratingsTable.usefulness, + usability: ratingsTable.usability, + }) + .from(ratingsTable) + .where(eq(ratingsTable.toolId, params.data.id)) + .orderBy(asc(ratingsTable.createdAt)); + res.json( + ratings.map((r) => ({ + date: r.date, + usefulness: r.usefulness, + usability: r.usability, + combined: (r.usefulness + r.usability) / 2, + })), + ); +}); + +router.get("/compare", requireFeature("compare"), async (req, res): Promise => { + const ids = String(req.query.ids ?? "") + .split(",") + .map((s) => Number(s.trim())) + .filter((n) => Number.isInteger(n) && n > 0); + if (ids.length === 0) { + res.status(400).json({ error: "Provide at least one ids value, e.g. ?ids=1,2,3" }); + return; + } + if (ids.length > 8) { + res.status(400).json({ error: "Maximum of 8 tools can be compared" }); + return; + } + const tools = await db.select().from(toolsTable).where(and(isNull(toolsTable.deletedAt), inArray(toolsTable.id, ids))); + if (tools.length === 0) { + res.status(404).json({ error: "No tools found" }); + return; + } + const toolIds = tools.map((t) => t.id); + const ratings = await db + .select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) + .from(ratingsTable) + .where(inArray(ratingsTable.toolId, toolIds)); + const byTool = new Map(); + for (const r of ratings) { + const list = byTool.get(r.toolId) ?? []; + list.push({ usefulness: r.usefulness, usability: r.usability }); + byTool.set(r.toolId, list); + } + const byId = new Map(tools.map((t) => [t.id, t])); + const ordered = ids.filter((id) => byId.has(id)).map((id) => buildToolWithStats(byId.get(id)!, byTool.get(id) ?? [])); + res.json(ordered); +}); + router.patch("/tools/:id", requireAuth, async (req, res): Promise => { const params = UpdateToolParams.safeParse(req.params); if (!params.success) { diff --git a/artifacts/toolrate/src/App.tsx b/artifacts/toolrate/src/App.tsx index b490c7b..aad3b60 100644 --- a/artifacts/toolrate/src/App.tsx +++ b/artifacts/toolrate/src/App.tsx @@ -13,6 +13,7 @@ import Analytics from "@/pages/analytics"; import Admin from "@/pages/admin"; import Redundancy from "@/pages/redundancy"; import Trash from "@/pages/trash"; +import Compare from "@/pages/compare"; import Login from "@/pages/login"; import NotFound from "@/pages/not-found"; @@ -34,6 +35,7 @@ function Router() { + diff --git a/artifacts/toolrate/src/components/compare-bar.tsx b/artifacts/toolrate/src/components/compare-bar.tsx new file mode 100644 index 0000000..0f1641e --- /dev/null +++ b/artifacts/toolrate/src/components/compare-bar.tsx @@ -0,0 +1,69 @@ +import { X, Scale, Lock } from "lucide-react"; +import type { ToolWithStats } from "@workspace/api-client-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; + +export function CompareBar({ + tools, + onRemove, + onClear, + canCompare, + onCompare, + onUpgrade, +}: { + tools: ToolWithStats[]; + onRemove: (id: number) => void; + onClear: () => void; + canCompare: boolean; + onCompare: () => void; + onUpgrade: () => void; +}) { + if (tools.length === 0) return null; + return ( +
+
+
+ +
+ {tools.map((t) => ( + + {t.name} + + + ))} + +
+ {canCompare ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/artifacts/toolrate/src/components/tool-card-wide.tsx b/artifacts/toolrate/src/components/tool-card-wide.tsx index 4d6a1ae..eb0536a 100644 --- a/artifacts/toolrate/src/components/tool-card-wide.tsx +++ b/artifacts/toolrate/src/components/tool-card-wide.tsx @@ -1,11 +1,20 @@ import { Link } from "wouter"; import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Star, MessageSquare, Wrench, ChevronRight } from "lucide-react"; +import { Star, MessageSquare, Wrench, ChevronRight, Scale } from "lucide-react"; import { ToolWithStats } from "@workspace/api-client-react"; import { MiniBarStack } from "@/components/mini-bars"; +import { cn } from "@/lib/utils"; -export function ToolCardWide({ tool, density }: { tool: ToolWithStats; density: "cozy" | "compact" }) { +export function ToolCardWide({ + tool, + density, + compare, +}: { + tool: ToolWithStats; + density: "cozy" | "compact"; + compare?: { selected: boolean; onToggle: () => void }; +}) { const compact = density === "compact"; return ( @@ -62,6 +71,25 @@ export function ToolCardWide({ tool, density }: { tool: ToolWithStats; density: {!compact && } + {compare && ( + + )} diff --git a/artifacts/toolrate/src/components/tool-card.tsx b/artifacts/toolrate/src/components/tool-card.tsx index 2205e57..c1fe2ac 100644 --- a/artifacts/toolrate/src/components/tool-card.tsx +++ b/artifacts/toolrate/src/components/tool-card.tsx @@ -1,11 +1,18 @@ import { ToolWithStats } from "@workspace/api-client-react"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Star, MessageSquare, Wrench } from "lucide-react"; +import { Star, MessageSquare, Wrench, Scale } from "lucide-react"; import { Link } from "wouter"; import { MiniBars } from "@/components/mini-bars"; +import { cn } from "@/lib/utils"; -export function ToolCard({ tool }: { tool: ToolWithStats }) { +export function ToolCard({ + tool, + compare, +}: { + tool: ToolWithStats; + compare?: { selected: boolean; onToggle: () => void }; +}) { return ( @@ -63,7 +70,28 @@ export function ToolCard({ tool }: { tool: ToolWithStats }) { - +
+ + {compare && ( + + )} +
diff --git a/artifacts/toolrate/src/components/tool-preview-card.tsx b/artifacts/toolrate/src/components/tool-preview-card.tsx new file mode 100644 index 0000000..1b4db18 --- /dev/null +++ b/artifacts/toolrate/src/components/tool-preview-card.tsx @@ -0,0 +1,54 @@ +import { Link } from "wouter"; +import type { ToolWithStats } from "@workspace/api-client-react"; + +import { Badge } from "@/components/ui/badge"; +import { RatingStars } from "@/components/rating-stars"; +import { MiniBars } from "@/components/mini-bars"; + +export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) { + return ( +
+
+
+
+
{tool.name}
+
{tool.category}
+
+ +
+
+ +

{tool.description}

+ +
+
+ {tool.ratingCount} review{tool.ratingCount === 1 ? "" : "s"} + + {tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "–"}/5 + +
+ +
+ + {tool.tags && tool.tags.length > 0 && ( +
+ {tool.tags.slice(0, 4).map((tag) => ( + + {tag} + + ))} + {tool.tags.length > 4 && ( + +{tool.tags.length - 4} + )} +
+ )} + + + View details + +
+ ); +} diff --git a/artifacts/toolrate/src/components/tool-row.tsx b/artifacts/toolrate/src/components/tool-row.tsx index 22e8872..479abe6 100644 --- a/artifacts/toolrate/src/components/tool-row.tsx +++ b/artifacts/toolrate/src/components/tool-row.tsx @@ -1,6 +1,6 @@ import { Link } from "wouter"; import { Badge } from "@/components/ui/badge"; -import { Star, MessageSquare, Wrench } from "lucide-react"; +import { Star, MessageSquare, Wrench, Scale } from "lucide-react"; import { ToolWithStats } from "@workspace/api-client-react"; import { cn } from "@/lib/utils"; import { MiniBarStack } from "@/components/mini-bars"; @@ -12,10 +12,12 @@ export function ToolRow({ tool, density, className, + compare, }: { tool: ToolWithStats; density: "cozy" | "compact"; className?: string; + compare?: { selected: boolean; onToggle: () => void }; }) { const compact = density === "compact"; return ( @@ -28,6 +30,25 @@ export function ToolRow({ )} >
+ {compare && ( + + )}
{tool.iconUrl ? ( { + const ids = new URLSearchParams(search) + .get("ids") + ?.split(",") + .map((s) => Number(s.trim())) + .filter((n) => Number.isInteger(n) && n > 0); + return ids && ids.length > 0 ? { ids: ids.join(",") } : undefined; + }, [search]); + + const canCompare = hasFeature("compare"); + + const { data: tools, isLoading: loading } = useListCompareTools( + params ?? { ids: "" }, + { + query: { + queryKey: getListCompareToolsQueryKey(params ?? { ids: "" }), + enabled: !!params && canCompare, + }, + }, + ); + + if (!authLoading && !canCompare) { + return ( + +
+ +

Compare requires a higher tier

+

Compare tools side-by-side is available to Premium and Enterprise users.

+ +
+
+ ); + } + + if (loading) { + return ( + +
+ + +
+
+ ); + } + + const list = tools ?? []; + if (list.length === 0) { + return ( + +
+ +

Nothing to compare

+

Select at least one tool to compare and come back here.

+ +
+
+ ); + } + + const bestRating = Math.max(...list.map((t) => t.avgCombined ?? 0)); + const bestUsefulness = Math.max(...list.map((t) => t.avgUsefulness ?? 0)); + const bestUsability = Math.max(...list.map((t) => t.avgUsability ?? 0)); + const bestReviews = Math.max(...list.map((t) => t.ratingCount)); + + const isBest = (value: number | null, best: number) => + value != null && value > 0 && value >= best && value === best; + + return ( + +
+
+

Compare Tools

+

Comparing {list.length} tool(s) side-by-side.

+
+ + + + Side-by-side comparison + Best value in each row is highlighted. + + + + + + Attribute + {list.map((t) => ( + +
+ + {t.name} + + {t.category} +
+
+ ))} +
+
+ + + Rating + {list.map((t) => ( + +
+ + {fmt(t.avgCombined)}/5 + {isBest(t.avgCombined, bestRating) && } +
+
+ ))} +
+ + Usefulness + {list.map((t) => ( + + {fmt(t.avgUsefulness)}/5 + + ))} + + + Usability + {list.map((t) => ( + + {fmt(t.avgUsability)}/5 + + ))} + + + Reviews + {list.map((t) => ( + + {t.ratingCount} + + ))} + + + Description + {list.map((t) => ( + + {t.description} + + ))} + + + Features + {list.map((t) => ( + +
+ {(t.features ?? []).map((f) => ( + {f} + ))} + {(t.features?.length ?? 0) === 0 && } +
+
+ ))} +
+ + Tags + {list.map((t) => ( + +
+ {(t.tags ?? []).map((tag) => ( + {tag} + ))} + {(t.tags?.length ?? 0) === 0 && } +
+
+ ))} +
+ + Last updated + {list.map((t) => ( + + {new Date(t.updatedAt).toLocaleDateString()} + + ))} + +
+
+
+
+ + +
+
+ ); +} diff --git a/artifacts/toolrate/src/pages/tool-detail.tsx b/artifacts/toolrate/src/pages/tool-detail.tsx index 9adacbe..9af92ad 100644 --- a/artifacts/toolrate/src/pages/tool-detail.tsx +++ b/artifacts/toolrate/src/pages/tool-detail.tsx @@ -7,6 +7,8 @@ import { getListToolRatingsQueryKey, useGetRatingDistribution, getGetRatingDistributionQueryKey, + useGetToolRatingHistory, + getGetToolRatingHistoryQueryKey, useCreateRating, getGetTopToolsQueryKey, getGetAnalyticsSummaryQueryKey, @@ -38,7 +40,7 @@ import { SelectValue, } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend } from "recharts"; import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon, DollarSign, Euro } from "lucide-react"; import { Link } from "wouter"; import { useAuth } from "@/hooks/use-auth"; @@ -203,6 +205,10 @@ export default function ToolDetail() { query: { enabled: !!id, queryKey: getGetRatingDistributionQueryKey({ toolId: id }) } }); + const { data: ratingHistory, isLoading: loadingRatingHistory } = useGetToolRatingHistory(id, { + query: { enabled: !!id, queryKey: getGetToolRatingHistoryQueryKey(id) } + }); + const createRating = useCreateRating(); const form = useForm({ @@ -695,6 +701,34 @@ export default function ToolDetail() { )} + + + + Score Trend + + + {loadingRatingHistory ? ( + + ) : ratingHistory && ratingHistory.length > 1 ? ( +
+ + + + + + (payload?.[0] ? format(new Date(payload[0].payload.date), "dd.MM.yyyy HH:mm") : "")} /> + + + + + + +
+ ) : ( +

Not enough ratings yet to show a trend.

+ )} +
+
{/* Right Column: Reviews */} diff --git a/artifacts/toolrate/src/pages/tools-browse.tsx b/artifacts/toolrate/src/pages/tools-browse.tsx index a312ed2..5d1f66f 100644 --- a/artifacts/toolrate/src/pages/tools-browse.tsx +++ b/artifacts/toolrate/src/pages/tools-browse.tsx @@ -9,10 +9,15 @@ import { Layout } from "@/components/layout"; import { ToolCard } from "@/components/tool-card"; import { ToolCardWide } from "@/components/tool-card-wide"; import { ToolRow, TABLE_GRID } from "@/components/tool-row"; +import { ToolPreviewCard } from "@/components/tool-preview-card"; +import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"; import { ViewToggle, type ViewMode } from "@/components/view-toggle"; import { DensityToggle, type Density } from "@/components/density-toggle"; import { FilterPopover } from "@/components/filter-popover"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { useBrowsePreferences, isViewMode, isDensity } from "@/hooks/use-browse-preferences"; +import { useAuth } from "@/hooks/use-auth"; +import { CompareBar } from "@/components/compare-bar"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -81,6 +86,14 @@ export default function ToolsBrowse() { : ListToolsSort.newest; const { serverView, serverDensity, localView, localDensity, persist } = useBrowsePreferences(); + const { hasFeature } = useAuth(); + + const [compareIds, setCompareIds] = useState([]); + const [compareUpsellOpen, setCompareUpsellOpen] = useState(false); + + function toggleCompare(id: number) { + setCompareIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); + } const [search, setSearch] = useState(initialSearch); const [searchInput, setSearchInput] = useState(initialSearch); @@ -385,7 +398,11 @@ export default function ToolsBrowse() { view === "grid" ? (
{allTools.map((tool) => ( - + toggleCompare(tool.id) }} + /> ))}
) : view === "rows" ? ( @@ -399,7 +416,11 @@ export default function ToolsBrowse() { className="absolute top-0 left-0 w-full" style={{ transform: `translateY(${vi.start}px)` }} > - + toggleCompare(allTools[vi.index].id) }} + />
))} @@ -437,7 +458,20 @@ export default function ToolsBrowse() { className="absolute top-0 left-0 w-full" style={{ transform: `translateY(${vi.start}px)` }} > - + + +
+ toggleCompare(tool.id) }} + /> +
+
+ + + +
); })} @@ -467,6 +501,32 @@ export default function ToolsBrowse() { )} + + compareIds.includes(t.id))} + onRemove={(id) => toggleCompare(id)} + onClear={() => setCompareIds([])} + canCompare={hasFeature("compare")} + onCompare={() => navigate(`/compare?ids=${compareIds.join(",")}`)} + onUpgrade={() => setCompareUpsellOpen(true)} + /> + + + + + Compare is a Premium feature + + Comparing tools side-by-side is available to Premium and Enterprise users. Upgrade your plan to unlock it. + + + + Not now + + Upgrade to Premium + + + + ); } diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 4cf2849..5dbca83 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -145,6 +145,13 @@ export interface Tool { deletedBy?: string | null; } +export interface RatingHistoryItem { + date: string; + usefulness: number; + usability: number; + combined: number; +} + export interface ToolWithStats { id: number; name: string; @@ -366,6 +373,13 @@ export const ListToolsSort = { recently_updated: 'recently_updated', } as const; +export type ListCompareToolsParams = { +/** + * Comma-separated tool ids + */ +ids: string; +}; + export type ListTrashedToolsParams = { search?: string; }; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 06f01b6..815bc8e 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -31,11 +31,13 @@ import type { GetTopToolsParams, HealthStatus, ListAuditLogsParams, + ListCompareToolsParams, ListToolsParams, ListTrashedToolsParams, LocalLoginInput, Rating, RatingDistribution, + RatingHistoryItem, RatingInput, RestoreTools200, Tool, @@ -375,6 +377,167 @@ export const useCreateTool = , return useMutation(getCreateToolMutationOptions(options)); } +export const getListCompareToolsUrl = (params: ListCompareToolsParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/api/compare?${stringifiedParams}` : `/api/compare` +} + +/** + * @summary Compare tools side by side (premium) + */ +export const listCompareTools = async (params: ListCompareToolsParams, options?: RequestInit): Promise => { + + return customFetch(getListCompareToolsUrl(params), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getListCompareToolsQueryKey = (params?: ListCompareToolsParams,) => { + return [ + `/api/compare`, ...(params ? [params] : []) + ] as const; + } + + +export const getListCompareToolsQueryOptions = >, TError = ErrorType>(params: ListCompareToolsParams, options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListCompareToolsQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => listCompareTools(params, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: QueryKey } +} + +export type ListCompareToolsQueryResult = NonNullable>> +export type ListCompareToolsQueryError = ErrorType + + +/** + * @summary Compare tools side by side (premium) + */ + +export function useListCompareTools>, TError = ErrorType>( + params: ListCompareToolsParams, options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} + + ): UseQueryResult & { queryKey: QueryKey } { + + const queryOptions = getListCompareToolsQueryOptions(params,options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: QueryKey }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + + +export const getGetToolRatingHistoryUrl = (id: number,) => { + + + + + return `/api/tools/${id}/rating-history` +} + +/** + * @summary Get a tool's rating history over time + */ +export const getToolRatingHistory = async (id: number, options?: RequestInit): Promise => { + + return customFetch(getGetToolRatingHistoryUrl(id), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getGetToolRatingHistoryQueryKey = (id: number,) => { + return [ + `/api/tools/${id}/rating-history` + ] as const; + } + + +export const getGetToolRatingHistoryQueryOptions = >, TError = ErrorType>(id: number, options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetToolRatingHistoryQueryKey(id); + + + + const queryFn: QueryFunction>> = ({ signal }) => getToolRatingHistory(id, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: QueryKey } +} + +export type GetToolRatingHistoryQueryResult = NonNullable>> +export type GetToolRatingHistoryQueryError = ErrorType + + +/** + * @summary Get a tool's rating history over time + */ + +export function useGetToolRatingHistory>, TError = ErrorType>( + id: number, options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} + + ): UseQueryResult & { queryKey: QueryKey } { + + const queryOptions = getGetToolRatingHistoryQueryOptions(id,options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: QueryKey }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + + export const getGetToolUrl = (id: number,) => { diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index ea74177..0cc6e18 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -126,6 +126,67 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /compare: + get: + operationId: listCompareTools + tags: [tools] + summary: Compare tools side by side (premium) + parameters: + - name: ids + in: query + required: true + schema: + type: string + description: Comma-separated tool ids + responses: + "200": + description: Tools in requested order + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ToolWithStats" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "403": + description: Premium feature required + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /tools/{id}/rating-history: + get: + operationId: getToolRatingHistory + tags: [tools] + summary: Get a tool's rating history over time + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Rating history + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RatingHistoryItem" + "400": + description: Invalid id + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /tools/{id}: get: operationId: getTool @@ -875,6 +936,20 @@ components: deletedBy: type: ["string", "null"] + RatingHistoryItem: + type: object + required: [date, usefulness, usability, combined] + properties: + date: + type: string + format: date-time + usefulness: + type: number + usability: + type: number + combined: + type: number + ToolWithStats: type: object required: [id, name, description, category, createdAt, updatedAt, ratingCount, avgUsefulness, avgUsability, avgCombined] diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 8b22557..e13639f 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -85,6 +85,49 @@ export const CreateToolBody = zod.object({ }) +/** + * @summary Compare tools side by side (premium) + */ +export const ListCompareToolsQueryParams = zod.object({ + "ids": zod.coerce.string().describe('Comma-separated tool ids') +}) + +export const ListCompareToolsResponseItem = zod.object({ + "id": zod.number(), + "name": zod.string(), + "description": zod.string(), + "category": zod.string(), + "websiteUrl": zod.string().nullish(), + "iconUrl": zod.string().nullish(), + "createdBy": zod.string().nullish(), + "features": zod.array(zod.string()).optional(), + "tags": zod.array(zod.string()).optional(), + "createdAt": zod.coerce.date(), + "updatedAt": zod.coerce.date(), + "ratingCount": zod.number(), + "avgUsefulness": zod.number().nullable(), + "avgUsability": zod.number().nullable(), + "avgCombined": zod.number().nullable() +}) +export const ListCompareToolsResponse = zod.array(ListCompareToolsResponseItem) + + +/** + * @summary Get a tool's rating history over time + */ +export const GetToolRatingHistoryParams = zod.object({ + "id": zod.coerce.number() +}) + +export const GetToolRatingHistoryResponseItem = zod.object({ + "date": zod.coerce.date(), + "usefulness": zod.number(), + "usability": zod.number(), + "combined": zod.number() +}) +export const GetToolRatingHistoryResponse = zod.array(GetToolRatingHistoryResponseItem) + + /** * @summary Get a tool by ID */ diff --git a/lib/api-zod/src/generated/types/index.ts b/lib/api-zod/src/generated/types/index.ts index 709c19a..da1bfaf 100644 --- a/lib/api-zod/src/generated/types/index.ts +++ b/lib/api-zod/src/generated/types/index.ts @@ -21,12 +21,14 @@ export * from './getTopToolsMetric'; export * from './getTopToolsParams'; export * from './healthStatus'; export * from './listAuditLogsParams'; +export * from './listCompareToolsParams'; export * from './listToolsParams'; export * from './listToolsSort'; export * from './listTrashedToolsParams'; export * from './localLoginInput'; export * from './rating'; export * from './ratingDistribution'; +export * from './ratingHistoryItem'; export * from './ratingInput'; export * from './restoreTools200'; export * from './scoreBucket'; diff --git a/lib/api-zod/src/generated/types/listCompareToolsParams.ts b/lib/api-zod/src/generated/types/listCompareToolsParams.ts new file mode 100644 index 0000000..e8ef32a --- /dev/null +++ b/lib/api-zod/src/generated/types/listCompareToolsParams.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * ToolRate API — Tool listing and rating platform + * OpenAPI spec version: 0.1.0 + */ + +export type ListCompareToolsParams = { +/** + * Comma-separated tool ids + */ +ids: string; +}; diff --git a/lib/api-zod/src/generated/types/ratingHistoryItem.ts b/lib/api-zod/src/generated/types/ratingHistoryItem.ts new file mode 100644 index 0000000..bf918e1 --- /dev/null +++ b/lib/api-zod/src/generated/types/ratingHistoryItem.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * ToolRate API — Tool listing and rating platform + * OpenAPI spec version: 0.1.0 + */ + +export interface RatingHistoryItem { + date: Date; + usefulness: number; + usability: number; + combined: number; +}