bake canonical build harness (deterministic /ship for forks) [landing-page]
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// KpiCard — pre-built KPI metric card wired to AnyDB aggregate
|
||||
// ---------------------------------------------------------------------------
|
||||
// PRE-INSTALLED. CodeWriterAgent just uses:
|
||||
// import { KpiCard } from "@/components/KpiCard"
|
||||
// <KpiCard collection="orders" metric="count" label="Total Orders" icon={ShoppingBag} />
|
||||
// <KpiCard collection="orders" metric="sum" field="total_cents" label="Revenue" format="currency" icon={DollarSign} />
|
||||
// <KpiCard collection="users" metric="count" filter={{ plan: 'pro' }} label="Pro Users" icon={Users} />
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useAggregate, useCollection } from "@/hooks/use-anydb";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
interface KpiCardProps {
|
||||
collection: string;
|
||||
metric: "count" | "sum" | "avg" | "min" | "max";
|
||||
field?: string;
|
||||
filter?: Record<string, any>;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
format?: "number" | "currency" | "percent";
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
trend?: number; // e.g. 12.5 for +12.5%
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function KpiCard({
|
||||
collection,
|
||||
metric,
|
||||
field,
|
||||
filter = {},
|
||||
label,
|
||||
icon: Icon,
|
||||
format = "number",
|
||||
prefix = "",
|
||||
suffix = "",
|
||||
trend,
|
||||
className = "",
|
||||
}: KpiCardProps) {
|
||||
const pipeline = useMemo(() => {
|
||||
const stages: any[] = [];
|
||||
if (Object.keys(filter).length > 0) {
|
||||
stages.push({ $match: filter });
|
||||
}
|
||||
|
||||
switch (metric) {
|
||||
case "count":
|
||||
stages.push({ $count: "value" });
|
||||
break;
|
||||
case "sum":
|
||||
stages.push({ $group: { _id: null, value: { $sum: `$${field}` } } });
|
||||
break;
|
||||
case "avg":
|
||||
stages.push({ $group: { _id: null, value: { $avg: `$${field}` } } });
|
||||
break;
|
||||
case "min":
|
||||
stages.push({ $group: { _id: null, value: { $min: `$${field}` } } });
|
||||
break;
|
||||
case "max":
|
||||
stages.push({ $group: { _id: null, value: { $max: `$${field}` } } });
|
||||
break;
|
||||
}
|
||||
|
||||
return stages;
|
||||
}, [metric, field, JSON.stringify(filter)]);
|
||||
|
||||
// For count, we can use the simpler useCollection total
|
||||
const useSimpleCount = metric === "count" && Object.keys(filter).length === 0;
|
||||
const { total: simpleTotal, loading: simpleLoading } = useCollection(collection, {
|
||||
limit: 1,
|
||||
autoFetch: useSimpleCount,
|
||||
});
|
||||
|
||||
const { data: aggData, loading: aggLoading } = useAggregate(collection, pipeline);
|
||||
|
||||
const loading = useSimpleCount ? simpleLoading : aggLoading;
|
||||
const rawValue = useSimpleCount
|
||||
? simpleTotal
|
||||
: aggData?.[0]?.value ?? 0;
|
||||
|
||||
const formatValue = (val: number): string => {
|
||||
switch (format) {
|
||||
case "currency":
|
||||
return `$${(val / 100).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
|
||||
case "percent":
|
||||
return `${val.toFixed(1)}%`;
|
||||
default:
|
||||
return val >= 1000 ? `${(val / 1000).toFixed(1)}K` : val.toLocaleString();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`${className}`}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-24" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{prefix}{formatValue(rawValue)}{suffix}
|
||||
</p>
|
||||
)}
|
||||
{trend !== undefined && !loading && (
|
||||
<p className={`text-xs font-medium ${trend >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{trend >= 0 ? "↑" : "↓"} {Math.abs(trend)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{Icon && (
|
||||
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user