feat(dashboard): cache snapshot data and add credits activity analytics
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -10,8 +9,9 @@ import { useRouter } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { normalizeTier, TIER_MONTHLY_CREDITS } from "@/lib/polar-products";
|
||||
import type { DashboardSnapshot } from "@/hooks/use-dashboard-snapshot";
|
||||
import { normalizeTier } from "@/lib/polar-products";
|
||||
import { formatCredits } from "@/lib/credits-activity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
@@ -28,16 +28,16 @@ const TIER_BADGE_STYLES: Record<string, string> = {
|
||||
|
||||
const LOW_CREDITS_THRESHOLD = 20;
|
||||
|
||||
export function CreditOverview() {
|
||||
type CreditOverviewProps = {
|
||||
balance?: DashboardSnapshot["balance"];
|
||||
subscription?: DashboardSnapshot["subscription"];
|
||||
usageStats?: DashboardSnapshot["usageStats"];
|
||||
};
|
||||
|
||||
export function CreditOverview({ balance, subscription, usageStats }: CreditOverviewProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const router = useRouter();
|
||||
const format = useFormatter();
|
||||
|
||||
const formatEurFromCents = (cents: number) =>
|
||||
format.number(cents / 100, { style: "currency", currency: "EUR" });
|
||||
const balance = useAuthQuery(api.credits.getBalance);
|
||||
const subscription = useAuthQuery(api.credits.getSubscription);
|
||||
const usageStats = useAuthQuery(api.credits.getUsageStats);
|
||||
const locale = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
if (balance === undefined) return;
|
||||
@@ -79,7 +79,7 @@ export function CreditOverview() {
|
||||
|
||||
// ── Computed Values ────────────────────────────────────────────────────
|
||||
const tier = normalizeTier(subscription.tier);
|
||||
const monthlyCredits = TIER_MONTHLY_CREDITS[tier];
|
||||
const monthlyCredits = usageStats.monthlyCredits;
|
||||
const usagePercent = monthlyCredits > 0
|
||||
? Math.min(100, Math.round((usageStats.monthlyUsage / monthlyCredits) * 100))
|
||||
: 0;
|
||||
@@ -98,9 +98,9 @@ export function CreditOverview() {
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Verfügbare Credits</p>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-3xl font-semibold tabular-nums tracking-tight">
|
||||
{formatEurFromCents(balance.available)}
|
||||
</span>
|
||||
<span className="text-3xl font-semibold tabular-nums tracking-tight">
|
||||
{formatCredits(balance.available, locale)}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
@@ -113,7 +113,7 @@ export function CreditOverview() {
|
||||
</div>
|
||||
{balance.reserved > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
({formatEurFromCents(balance.reserved)} reserviert)
|
||||
({formatCredits(balance.reserved, locale)} reserviert)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -132,8 +132,8 @@ export function CreditOverview() {
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatEurFromCents(usageStats.monthlyUsage)} von{" "}
|
||||
{formatEurFromCents(monthlyCredits)} verwendet
|
||||
{formatCredits(usageStats.monthlyUsage, locale)} von{" "}
|
||||
{formatCredits(monthlyCredits, locale)} verwendet
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{usageStats.totalGenerations} Generierungen
|
||||
|
||||
150
components/dashboard/credits-activity-chart.tsx
Normal file
150
components/dashboard/credits-activity-chart.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { Area, AreaChart, CartesianGrid, Line, XAxis, YAxis } from "recharts";
|
||||
import { ChartNoAxesCombined } from "lucide-react";
|
||||
|
||||
import {
|
||||
buildCreditsActivitySeries,
|
||||
calculateUsageActivityDomain,
|
||||
formatCredits,
|
||||
prioritizeRecentCreditTransactions,
|
||||
} from "@/lib/credits-activity";
|
||||
import type { DashboardSnapshot } from "@/hooks/use-dashboard-snapshot";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
const chartConfig = {
|
||||
usage: {
|
||||
label: "Verbrauch",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
activity: {
|
||||
label: "Aktivität",
|
||||
color: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
available: {
|
||||
label: "Verfügbar",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
type CreditsActivityChartProps = {
|
||||
balance?: DashboardSnapshot["balance"];
|
||||
recentTransactions?: DashboardSnapshot["recentTransactions"];
|
||||
};
|
||||
|
||||
export function CreditsActivityChart({ balance, recentTransactions }: CreditsActivityChartProps) {
|
||||
const locale = useLocale();
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (balance === undefined || recentTransactions === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const prioritized = prioritizeRecentCreditTransactions(recentTransactions, 40);
|
||||
|
||||
return buildCreditsActivitySeries(prioritized, balance.available, locale, 7);
|
||||
}, [balance, recentTransactions, locale]);
|
||||
|
||||
const usageDomain = useMemo(
|
||||
() => calculateUsageActivityDomain(chartData),
|
||||
[chartData],
|
||||
);
|
||||
|
||||
if (balance === undefined || recentTransactions === undefined) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6 shadow-sm shadow-foreground/3">
|
||||
<div className="h-[240px] animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6 shadow-sm shadow-foreground/3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<ChartNoAxesCombined className="size-3.5 text-muted-foreground" />
|
||||
Credits Verlauf
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Noch keine verbrauchsrelevante Aktivität vorhanden.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 rounded-xl border bg-card p-5 shadow-sm shadow-foreground/3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<ChartNoAxesCombined className="size-3.5 text-muted-foreground" />
|
||||
Credits Verlauf
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Verfügbar: {formatCredits(balance.available, locale)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[240px] w-full min-w-0 aspect-auto"
|
||||
>
|
||||
<AreaChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="day" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis
|
||||
yAxisId="usage"
|
||||
orientation="right"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
width={44}
|
||||
allowDecimals={false}
|
||||
domain={usageDomain}
|
||||
/>
|
||||
<YAxis yAxisId="available" hide />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value: number) => formatCredits(Number(value), locale)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="activity"
|
||||
yAxisId="usage"
|
||||
type="monotone"
|
||||
fill="var(--color-activity)"
|
||||
fillOpacity={0.15}
|
||||
stroke="var(--color-activity)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
dataKey="usage"
|
||||
yAxisId="usage"
|
||||
type="monotone"
|
||||
fill="var(--color-usage)"
|
||||
fillOpacity={0.25}
|
||||
stroke="var(--color-usage)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Line
|
||||
dataKey="available"
|
||||
yAxisId="available"
|
||||
type="monotone"
|
||||
dot={false}
|
||||
stroke="var(--color-available)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { useFormatter } from "next-intl";
|
||||
import { useFormatter, useLocale } from "next-intl";
|
||||
import { Activity, Coins } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { DashboardSnapshot } from "@/hooks/use-dashboard-snapshot";
|
||||
import { formatCredits } from "@/lib/credits-activity";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -45,12 +45,14 @@ function truncatedDescription(text: string, maxLen = 40) {
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RecentTransactions() {
|
||||
type RecentTransactionsProps = {
|
||||
recentTransactions?: DashboardSnapshot["recentTransactions"];
|
||||
};
|
||||
|
||||
export function RecentTransactions({ recentTransactions }: RecentTransactionsProps) {
|
||||
const format = useFormatter();
|
||||
const locale = useLocale();
|
||||
const [now, setNow] = useState<number | null>(null);
|
||||
const transactions = useAuthQuery(api.credits.getRecentTransactions, {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateNow = () => {
|
||||
@@ -67,8 +69,7 @@ export function RecentTransactions() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const formatEurFromCents = (cents: number) =>
|
||||
format.number(cents / 100, { style: "currency", currency: "EUR" });
|
||||
const transactions = recentTransactions ?? [];
|
||||
|
||||
const formatRelativeTime = (timestamp: number) => {
|
||||
const referenceNow = now ?? timestamp;
|
||||
@@ -85,7 +86,7 @@ export function RecentTransactions() {
|
||||
};
|
||||
|
||||
// ── Loading State ──────────────────────────────────────────────────────
|
||||
if (transactions === undefined) {
|
||||
if (recentTransactions === undefined) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6 shadow-sm shadow-foreground/3">
|
||||
<div className="mb-4 flex items-center gap-2 text-sm font-medium">
|
||||
@@ -173,7 +174,7 @@ export function RecentTransactions() {
|
||||
)}
|
||||
>
|
||||
{isCredit ? "+" : "−"}
|
||||
{formatEurFromCents(Math.abs(t.amount))}
|
||||
{formatCredits(Math.abs(t.amount), locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
166
components/ui/chart.tsx
Normal file
166
components/ui/chart.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
color?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-legend-item_text]:fill-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartStyle({ id, config }: { id: string; config: ChartConfig }) {
|
||||
const colorConfig = Object.entries(config).filter(([, item]) => item.color);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(config)
|
||||
.map(([key, item]) => item.color ? `[data-chart=${id}] { --color-${key}: ${item.color}; }` : "")
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
type TooltipContentProps = {
|
||||
active?: boolean;
|
||||
payload?: Array<{ dataKey?: string | number; value?: number; color?: string }>;
|
||||
className?: string;
|
||||
hideLabel?: boolean;
|
||||
formatter?: (value: number, name: string) => React.ReactNode;
|
||||
};
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
hideLabel = false,
|
||||
formatter,
|
||||
}: TooltipContentProps) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("grid min-w-[8rem] gap-1 rounded-lg border bg-background px-2.5 py-1.5 text-xs shadow-md", className)}>
|
||||
{payload.map((item) => {
|
||||
const key = String(item.dataKey);
|
||||
const conf = config[key];
|
||||
const value = Number(item.value ?? 0);
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">{hideLabel ? key : conf?.label ?? key}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatter ? formatter(value, key) : value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
type LegendContentProps = {
|
||||
className?: string;
|
||||
payload?: Array<{ dataKey?: string | number; color?: string }>;
|
||||
};
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
payload,
|
||||
}: LegendContentProps) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("mt-2 flex flex-wrap items-center gap-4 text-xs", className)}>
|
||||
{payload.map((item) => {
|
||||
const key = String(item.dataKey);
|
||||
const conf = config[key];
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: item.color ?? `var(--color-${key})` }}
|
||||
/>
|
||||
<span className="text-muted-foreground">{conf?.label ?? key}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
};
|
||||
Reference in New Issue
Block a user