merge(feature/dashboard): credits analytics and dashboard snapshot cache
This commit is contained in:
@@ -33,10 +33,11 @@ import { api } from "@/convex/_generated/api";
|
|||||||
import type { Doc } from "@/convex/_generated/dataModel";
|
import type { Doc } from "@/convex/_generated/dataModel";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { CreditOverview } from "@/components/dashboard/credit-overview";
|
import { CreditOverview } from "@/components/dashboard/credit-overview";
|
||||||
|
import { CreditsActivityChart } from "@/components/dashboard/credits-activity-chart";
|
||||||
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
|
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
|
||||||
import CanvasCard from "@/components/dashboard/canvas-card";
|
import CanvasCard from "@/components/dashboard/canvas-card";
|
||||||
|
import { useDashboardSnapshot } from "@/hooks/use-dashboard-snapshot";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
|
||||||
|
|
||||||
function getInitials(nameOrEmail: string) {
|
function getInitials(nameOrEmail: string) {
|
||||||
const normalized = nameOrEmail.trim();
|
const normalized = nameOrEmail.trim();
|
||||||
@@ -56,10 +57,7 @@ export function DashboardPageClient() {
|
|||||||
const welcomeToastSentRef = useRef(false);
|
const welcomeToastSentRef = useRef(false);
|
||||||
const { theme = "system", setTheme } = useTheme();
|
const { theme = "system", setTheme } = useTheme();
|
||||||
const { data: session, isPending: isSessionPending } = authClient.useSession();
|
const { data: session, isPending: isSessionPending } = authClient.useSession();
|
||||||
const canvases = useAuthQuery(
|
const { snapshot: dashboardSnapshot } = useDashboardSnapshot(session?.user?.id);
|
||||||
api.canvases.list,
|
|
||||||
session?.user && !isSessionPending ? {} : "skip",
|
|
||||||
);
|
|
||||||
const createCanvas = useMutation(api.canvases.create);
|
const createCanvas = useMutation(api.canvases.create);
|
||||||
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
|
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
|
||||||
const [hasClientMounted, setHasClientMounted] = useState(false);
|
const [hasClientMounted, setHasClientMounted] = useState(false);
|
||||||
@@ -70,6 +68,7 @@ export function DashboardPageClient() {
|
|||||||
|
|
||||||
const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer";
|
const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer";
|
||||||
const initials = getInitials(displayName);
|
const initials = getInitials(displayName);
|
||||||
|
const canvases = dashboardSnapshot?.canvases;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.user || welcomeToastSentRef.current) return;
|
if (!session?.user || welcomeToastSentRef.current) return;
|
||||||
@@ -190,7 +189,11 @@ export function DashboardPageClient() {
|
|||||||
<Coins className="size-3.5 text-muted-foreground" />
|
<Coins className="size-3.5 text-muted-foreground" />
|
||||||
Credit-Übersicht
|
Credit-Übersicht
|
||||||
</div>
|
</div>
|
||||||
<CreditOverview />
|
<CreditOverview
|
||||||
|
balance={dashboardSnapshot?.balance}
|
||||||
|
subscription={dashboardSnapshot?.subscription}
|
||||||
|
usageStats={dashboardSnapshot?.usageStats}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
@@ -238,8 +241,12 @@ export function DashboardPageClient() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12 grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] [&>*]:min-w-0">
|
||||||
<RecentTransactions />
|
<CreditsActivityChart
|
||||||
|
balance={dashboardSnapshot?.balance}
|
||||||
|
recentTransactions={dashboardSnapshot?.recentTransactions}
|
||||||
|
/>
|
||||||
|
<RecentTransactions recentTransactions={dashboardSnapshot?.recentTransactions} />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useFormatter, useTranslations } from "next-intl";
|
|
||||||
import { CreditCard } from "lucide-react";
|
import { CreditCard } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -10,8 +9,9 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { api } from "@/convex/_generated/api";
|
import type { DashboardSnapshot } from "@/hooks/use-dashboard-snapshot";
|
||||||
import { normalizeTier, TIER_MONTHLY_CREDITS } from "@/lib/polar-products";
|
import { normalizeTier } from "@/lib/polar-products";
|
||||||
|
import { formatCredits } from "@/lib/credits-activity";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
@@ -28,16 +28,16 @@ const TIER_BADGE_STYLES: Record<string, string> = {
|
|||||||
|
|
||||||
const LOW_CREDITS_THRESHOLD = 20;
|
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 t = useTranslations('toasts');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const format = useFormatter();
|
const locale = useLocale();
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (balance === undefined) return;
|
if (balance === undefined) return;
|
||||||
@@ -79,7 +79,7 @@ export function CreditOverview() {
|
|||||||
|
|
||||||
// ── Computed Values ────────────────────────────────────────────────────
|
// ── Computed Values ────────────────────────────────────────────────────
|
||||||
const tier = normalizeTier(subscription.tier);
|
const tier = normalizeTier(subscription.tier);
|
||||||
const monthlyCredits = TIER_MONTHLY_CREDITS[tier];
|
const monthlyCredits = usageStats.monthlyCredits;
|
||||||
const usagePercent = monthlyCredits > 0
|
const usagePercent = monthlyCredits > 0
|
||||||
? Math.min(100, Math.round((usageStats.monthlyUsage / monthlyCredits) * 100))
|
? Math.min(100, Math.round((usageStats.monthlyUsage / monthlyCredits) * 100))
|
||||||
: 0;
|
: 0;
|
||||||
@@ -98,9 +98,9 @@ export function CreditOverview() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">Verfügbare Credits</p>
|
<p className="text-sm text-muted-foreground">Verfügbare Credits</p>
|
||||||
<div className="flex items-baseline gap-3">
|
<div className="flex items-baseline gap-3">
|
||||||
<span className="text-3xl font-semibold tabular-nums tracking-tight">
|
<span className="text-3xl font-semibold tabular-nums tracking-tight">
|
||||||
{formatEurFromCents(balance.available)}
|
{formatCredits(balance.available, locale)}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -113,7 +113,7 @@ export function CreditOverview() {
|
|||||||
</div>
|
</div>
|
||||||
{balance.reserved > 0 && (
|
{balance.reserved > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
({formatEurFromCents(balance.reserved)} reserviert)
|
({formatCredits(balance.reserved, locale)} reserviert)
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -132,8 +132,8 @@ export function CreditOverview() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{formatEurFromCents(usageStats.monthlyUsage)} von{" "}
|
{formatCredits(usageStats.monthlyUsage, locale)} von{" "}
|
||||||
{formatEurFromCents(monthlyCredits)} verwendet
|
{formatCredits(monthlyCredits, locale)} verwendet
|
||||||
</span>
|
</span>
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{usageStats.totalGenerations} Generierungen
|
{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";
|
"use client";
|
||||||
|
|
||||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
import { useFormatter, useLocale } from "next-intl";
|
||||||
import { useFormatter } from "next-intl";
|
|
||||||
import { Activity, Coins } from "lucide-react";
|
import { Activity, Coins } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -45,12 +45,14 @@ function truncatedDescription(text: string, maxLen = 40) {
|
|||||||
// Component
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function RecentTransactions() {
|
type RecentTransactionsProps = {
|
||||||
|
recentTransactions?: DashboardSnapshot["recentTransactions"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecentTransactions({ recentTransactions }: RecentTransactionsProps) {
|
||||||
const format = useFormatter();
|
const format = useFormatter();
|
||||||
|
const locale = useLocale();
|
||||||
const [now, setNow] = useState<number | null>(null);
|
const [now, setNow] = useState<number | null>(null);
|
||||||
const transactions = useAuthQuery(api.credits.getRecentTransactions, {
|
|
||||||
limit: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateNow = () => {
|
const updateNow = () => {
|
||||||
@@ -67,8 +69,7 @@ export function RecentTransactions() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatEurFromCents = (cents: number) =>
|
const transactions = recentTransactions ?? [];
|
||||||
format.number(cents / 100, { style: "currency", currency: "EUR" });
|
|
||||||
|
|
||||||
const formatRelativeTime = (timestamp: number) => {
|
const formatRelativeTime = (timestamp: number) => {
|
||||||
const referenceNow = now ?? timestamp;
|
const referenceNow = now ?? timestamp;
|
||||||
@@ -85,7 +86,7 @@ export function RecentTransactions() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Loading State ──────────────────────────────────────────────────────
|
// ── Loading State ──────────────────────────────────────────────────────
|
||||||
if (transactions === undefined) {
|
if (recentTransactions === undefined) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-6 shadow-sm shadow-foreground/3">
|
<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">
|
<div className="mb-4 flex items-center gap-2 text-sm font-medium">
|
||||||
@@ -173,7 +174,7 @@ export function RecentTransactions() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCredit ? "+" : "−"}
|
{isCredit ? "+" : "−"}
|
||||||
{formatEurFromCents(Math.abs(t.amount))}
|
{formatCredits(Math.abs(t.amount), locale)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -17,6 +17,7 @@ import type * as auth from "../auth.js";
|
|||||||
import type * as batch_validation_utils from "../batch_validation_utils.js";
|
import type * as batch_validation_utils from "../batch_validation_utils.js";
|
||||||
import type * as canvases from "../canvases.js";
|
import type * as canvases from "../canvases.js";
|
||||||
import type * as credits from "../credits.js";
|
import type * as credits from "../credits.js";
|
||||||
|
import type * as dashboard from "../dashboard.js";
|
||||||
import type * as edges from "../edges.js";
|
import type * as edges from "../edges.js";
|
||||||
import type * as export_ from "../export.js";
|
import type * as export_ from "../export.js";
|
||||||
import type * as freepik from "../freepik.js";
|
import type * as freepik from "../freepik.js";
|
||||||
@@ -48,6 +49,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
batch_validation_utils: typeof batch_validation_utils;
|
batch_validation_utils: typeof batch_validation_utils;
|
||||||
canvases: typeof canvases;
|
canvases: typeof canvases;
|
||||||
credits: typeof credits;
|
credits: typeof credits;
|
||||||
|
dashboard: typeof dashboard;
|
||||||
edges: typeof edges;
|
edges: typeof edges;
|
||||||
export: typeof export_;
|
export: typeof export_;
|
||||||
freepik: typeof freepik;
|
freepik: typeof freepik;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { v, ConvexError } from "convex/values";
|
|||||||
import { optionalAuth, requireAuth } from "./helpers";
|
import { optionalAuth, requireAuth } from "./helpers";
|
||||||
import { internal } from "./_generated/api";
|
import { internal } from "./_generated/api";
|
||||||
import { MONTHLY_TIER_CREDITS, normalizeBillingTier } from "../lib/tier-credits";
|
import { MONTHLY_TIER_CREDITS, normalizeBillingTier } from "../lib/tier-credits";
|
||||||
|
import { prioritizeRecentCreditTransactions } from "../lib/credits-activity";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tier-Konfiguration
|
// Tier-Konfiguration
|
||||||
@@ -239,12 +240,15 @@ export const getRecentTransactions = query({
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const limit = args.limit ?? 10;
|
const limit = args.limit ?? 10;
|
||||||
|
const readLimit = Math.min(Math.max(limit * 4, 20), 100);
|
||||||
|
|
||||||
return await ctx.db
|
const transactions = await ctx.db
|
||||||
.query("creditTransactions")
|
.query("creditTransactions")
|
||||||
.withIndex("by_user", (q) => q.eq("userId", user.userId))
|
.withIndex("by_user", (q) => q.eq("userId", user.userId))
|
||||||
.order("desc")
|
.order("desc")
|
||||||
.take(limit);
|
.take(readLimit);
|
||||||
|
|
||||||
|
return prioritizeRecentCreditTransactions(transactions, limit);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
102
convex/dashboard.ts
Normal file
102
convex/dashboard.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { query } from "./_generated/server";
|
||||||
|
|
||||||
|
import { optionalAuth } from "./helpers";
|
||||||
|
import { prioritizeRecentCreditTransactions } from "../lib/credits-activity";
|
||||||
|
import { MONTHLY_TIER_CREDITS, normalizeBillingTier } from "../lib/tier-credits";
|
||||||
|
|
||||||
|
const DEFAULT_TIER = "free" as const;
|
||||||
|
const DEFAULT_SUBSCRIPTION_STATUS = "active" as const;
|
||||||
|
|
||||||
|
export const getSnapshot = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const user = await optionalAuth(ctx);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
balance: { balance: 0, reserved: 0, available: 0, monthlyAllocation: 0 },
|
||||||
|
subscription: {
|
||||||
|
tier: DEFAULT_TIER,
|
||||||
|
status: DEFAULT_SUBSCRIPTION_STATUS,
|
||||||
|
currentPeriodEnd: undefined,
|
||||||
|
},
|
||||||
|
usageStats: {
|
||||||
|
monthlyUsage: 0,
|
||||||
|
totalGenerations: 0,
|
||||||
|
monthlyCredits: MONTHLY_TIER_CREDITS[DEFAULT_TIER],
|
||||||
|
},
|
||||||
|
recentTransactions: [],
|
||||||
|
canvases: [],
|
||||||
|
generatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [balanceRow, subscriptionRow, usageTransactions, recentTransactionsRaw, canvases] =
|
||||||
|
await Promise.all([
|
||||||
|
ctx.db
|
||||||
|
.query("creditBalances")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", user.userId))
|
||||||
|
.unique(),
|
||||||
|
ctx.db
|
||||||
|
.query("subscriptions")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", user.userId))
|
||||||
|
.order("desc")
|
||||||
|
.first(),
|
||||||
|
ctx.db
|
||||||
|
.query("creditTransactions")
|
||||||
|
.withIndex("by_user_type", (q) => q.eq("userId", user.userId).eq("type", "usage"))
|
||||||
|
.order("desc")
|
||||||
|
.collect(),
|
||||||
|
ctx.db
|
||||||
|
.query("creditTransactions")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", user.userId))
|
||||||
|
.order("desc")
|
||||||
|
.take(80),
|
||||||
|
ctx.db
|
||||||
|
.query("canvases")
|
||||||
|
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
|
||||||
|
.order("desc")
|
||||||
|
.collect(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tier = normalizeBillingTier(subscriptionRow?.tier);
|
||||||
|
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime();
|
||||||
|
let monthlyUsage = 0;
|
||||||
|
let totalGenerations = 0;
|
||||||
|
|
||||||
|
for (const transaction of usageTransactions) {
|
||||||
|
if (transaction._creationTime < monthStart) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.status === "committed") {
|
||||||
|
monthlyUsage += Math.abs(transaction.amount);
|
||||||
|
totalGenerations += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const balance = {
|
||||||
|
balance: balanceRow?.balance ?? 0,
|
||||||
|
reserved: balanceRow?.reserved ?? 0,
|
||||||
|
available: (balanceRow?.balance ?? 0) - (balanceRow?.reserved ?? 0),
|
||||||
|
monthlyAllocation: balanceRow?.monthlyAllocation ?? MONTHLY_TIER_CREDITS[tier],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance,
|
||||||
|
subscription: {
|
||||||
|
tier: subscriptionRow?.tier ?? DEFAULT_TIER,
|
||||||
|
status: subscriptionRow?.status ?? DEFAULT_SUBSCRIPTION_STATUS,
|
||||||
|
currentPeriodEnd: subscriptionRow?.currentPeriodEnd,
|
||||||
|
},
|
||||||
|
usageStats: {
|
||||||
|
monthlyUsage,
|
||||||
|
totalGenerations,
|
||||||
|
monthlyCredits: MONTHLY_TIER_CREDITS[tier],
|
||||||
|
},
|
||||||
|
recentTransactions: prioritizeRecentCreditTransactions(recentTransactionsRaw, 20),
|
||||||
|
canvases,
|
||||||
|
generatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
60
hooks/use-dashboard-snapshot.ts
Normal file
60
hooks/use-dashboard-snapshot.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import type { FunctionReturnType } from "convex/server";
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api";
|
||||||
|
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||||
|
import {
|
||||||
|
clearDashboardSnapshotCache,
|
||||||
|
readDashboardSnapshotCache,
|
||||||
|
writeDashboardSnapshotCache,
|
||||||
|
} from "@/lib/dashboard-snapshot-cache";
|
||||||
|
|
||||||
|
export type DashboardSnapshot = FunctionReturnType<typeof api.dashboard.getSnapshot>;
|
||||||
|
|
||||||
|
export function useDashboardSnapshot(userId?: string | null): {
|
||||||
|
snapshot: DashboardSnapshot | undefined;
|
||||||
|
source: "live" | "cache" | "none";
|
||||||
|
} {
|
||||||
|
const liveSnapshot = useAuthQuery(api.dashboard.getSnapshot, userId ? {} : "skip");
|
||||||
|
const cachedSnapshot = useMemo(() => {
|
||||||
|
if (!userId) return null;
|
||||||
|
const cached = readDashboardSnapshotCache<DashboardSnapshot>(userId);
|
||||||
|
return cached?.snapshot ?? null;
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId || !liveSnapshot) return;
|
||||||
|
writeDashboardSnapshotCache(userId, liveSnapshot);
|
||||||
|
}, [userId, liveSnapshot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const previousUserId = window.sessionStorage.getItem("ls-last-dashboard-user");
|
||||||
|
if (previousUserId) {
|
||||||
|
clearDashboardSnapshotCache(previousUserId);
|
||||||
|
window.sessionStorage.removeItem("ls-last-dashboard-user");
|
||||||
|
}
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.sessionStorage.setItem("ls-last-dashboard-user", userId);
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (liveSnapshot) {
|
||||||
|
return { snapshot: liveSnapshot, source: "live" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedSnapshot) {
|
||||||
|
return { snapshot: cachedSnapshot, source: "cache" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { snapshot: undefined, source: "none" as const };
|
||||||
|
}, [cachedSnapshot, liveSnapshot]);
|
||||||
|
}
|
||||||
136
lib/credits-activity.ts
Normal file
136
lib/credits-activity.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
type CreditTransactionType =
|
||||||
|
| "subscription"
|
||||||
|
| "topup"
|
||||||
|
| "usage"
|
||||||
|
| "reservation"
|
||||||
|
| "refund";
|
||||||
|
|
||||||
|
type CreditTransactionStatus = "committed" | "reserved" | "released" | "failed";
|
||||||
|
|
||||||
|
export type CreditTransactionLike = {
|
||||||
|
_creationTime: number;
|
||||||
|
type: CreditTransactionType;
|
||||||
|
status: CreditTransactionStatus;
|
||||||
|
amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreditActivityPoint = {
|
||||||
|
day: string;
|
||||||
|
usage: number;
|
||||||
|
activity: number;
|
||||||
|
available: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_USAGE_DOMAIN_MAX = 8;
|
||||||
|
const EMPTY_USAGE_DOMAIN_MAX = 10;
|
||||||
|
const USAGE_HEADROOM_RATIO = 0.2;
|
||||||
|
const MIN_USAGE_HEADROOM = 2;
|
||||||
|
|
||||||
|
const TX_PRIORITY: Record<CreditTransactionType, number> = {
|
||||||
|
usage: 0,
|
||||||
|
reservation: 1,
|
||||||
|
refund: 2,
|
||||||
|
topup: 3,
|
||||||
|
subscription: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_PRIORITY: Record<CreditTransactionStatus, number> = {
|
||||||
|
committed: 0,
|
||||||
|
reserved: 1,
|
||||||
|
released: 2,
|
||||||
|
failed: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatCredits(value: number, locale: string) {
|
||||||
|
return `${new Intl.NumberFormat(locale).format(value)} Cr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prioritizeRecentCreditTransactions<T extends CreditTransactionLike>(
|
||||||
|
transactions: readonly T[],
|
||||||
|
limit: number,
|
||||||
|
) {
|
||||||
|
return [...transactions]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const primary = TX_PRIORITY[a.type] - TX_PRIORITY[b.type];
|
||||||
|
if (primary !== 0) {
|
||||||
|
return primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOrder = STATUS_PRIORITY[a.status] - STATUS_PRIORITY[b.status];
|
||||||
|
if (statusOrder !== 0) {
|
||||||
|
return statusOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return b._creationTime - a._creationTime;
|
||||||
|
})
|
||||||
|
.slice(0, Math.max(0, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCreditsActivitySeries<T extends CreditTransactionLike>(
|
||||||
|
transactions: readonly T[],
|
||||||
|
availableCredits: number,
|
||||||
|
locale: string,
|
||||||
|
maxPoints = 7,
|
||||||
|
): CreditActivityPoint[] {
|
||||||
|
const formatter = new Intl.DateTimeFormat(locale, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
});
|
||||||
|
|
||||||
|
const byDay = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
date: Date;
|
||||||
|
usage: number;
|
||||||
|
activity: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const tx of transactions) {
|
||||||
|
const date = new Date(tx._creationTime);
|
||||||
|
const key = new Date(date.getFullYear(), date.getMonth(), date.getDate()).toISOString();
|
||||||
|
const current = byDay.get(key) ?? { date, usage: 0, activity: 0 };
|
||||||
|
const absAmount = Math.abs(tx.amount);
|
||||||
|
|
||||||
|
if (tx.type === "usage" && tx.status === "committed") {
|
||||||
|
current.usage += absAmount;
|
||||||
|
current.activity += absAmount;
|
||||||
|
} else if (
|
||||||
|
tx.type === "reservation" &&
|
||||||
|
(tx.status === "reserved" || tx.status === "released")
|
||||||
|
) {
|
||||||
|
current.activity += absAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
byDay.set(key, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byDay.entries()]
|
||||||
|
.sort((a, b) => a[1].date.getTime() - b[1].date.getTime())
|
||||||
|
.slice(-Math.max(1, maxPoints))
|
||||||
|
.map(([, point]) => ({
|
||||||
|
day: formatter.format(point.date).replace(/\.$/, ""),
|
||||||
|
usage: point.usage,
|
||||||
|
activity: point.activity,
|
||||||
|
available: availableCredits,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateUsageActivityDomain(
|
||||||
|
points: readonly Pick<CreditActivityPoint, "usage" | "activity">[],
|
||||||
|
): [number, number] {
|
||||||
|
const maxUsageOrActivity = points.reduce((maxValue, point) => {
|
||||||
|
return Math.max(maxValue, point.usage, point.activity);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
if (maxUsageOrActivity <= 0) {
|
||||||
|
return [0, EMPTY_USAGE_DOMAIN_MAX];
|
||||||
|
}
|
||||||
|
|
||||||
|
const headroom = Math.max(
|
||||||
|
MIN_USAGE_HEADROOM,
|
||||||
|
Math.ceil(maxUsageOrActivity * USAGE_HEADROOM_RATIO),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [0, Math.max(MIN_USAGE_DOMAIN_MAX, maxUsageOrActivity + headroom)];
|
||||||
|
}
|
||||||
122
lib/dashboard-snapshot-cache.ts
Normal file
122
lib/dashboard-snapshot-cache.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
const STORAGE_NAMESPACE = "lemonspace.dashboard";
|
||||||
|
const CACHE_VERSION = 1;
|
||||||
|
const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
type DashboardSnapshotCachePayload<TSnapshot> = {
|
||||||
|
version: number;
|
||||||
|
cachedAt: number;
|
||||||
|
snapshot: TSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getLocalStorage(): Storage | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
return window.localStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is JsonRecord {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParse(raw: string | null): unknown {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKey(userId: string): string {
|
||||||
|
return `${STORAGE_NAMESPACE}:snapshot:v${CACHE_VERSION}:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeGet(storage: Storage, key: string): string | null {
|
||||||
|
try {
|
||||||
|
if (typeof storage.getItem === "function") {
|
||||||
|
return storage.getItem(key);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage read failures in UX cache layer.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeSet(storage: Storage, key: string, value: string): void {
|
||||||
|
try {
|
||||||
|
if (typeof storage.setItem === "function") {
|
||||||
|
storage.setItem(key, value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage write failures in UX cache layer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRemove(storage: Storage, key: string): void {
|
||||||
|
try {
|
||||||
|
if (typeof storage.removeItem === "function") {
|
||||||
|
storage.removeItem(key);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage remove failures in UX cache layer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDashboardSnapshotCache<TSnapshot>(
|
||||||
|
userId: string,
|
||||||
|
options?: { now?: number; ttlMs?: number },
|
||||||
|
): DashboardSnapshotCachePayload<TSnapshot> | null {
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (!storage) return null;
|
||||||
|
|
||||||
|
const parsed = safeParse(safeGet(storage, cacheKey(userId)));
|
||||||
|
if (!isRecord(parsed)) return null;
|
||||||
|
if (parsed.version !== CACHE_VERSION) return null;
|
||||||
|
if (typeof parsed.cachedAt !== "number") return null;
|
||||||
|
if (!("snapshot" in parsed)) return null;
|
||||||
|
|
||||||
|
const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
|
||||||
|
const now = options?.now ?? Date.now();
|
||||||
|
if (now - parsed.cachedAt > ttlMs) {
|
||||||
|
safeRemove(storage, cacheKey(userId));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
cachedAt: parsed.cachedAt,
|
||||||
|
snapshot: parsed.snapshot as TSnapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeDashboardSnapshotCache<TSnapshot>(
|
||||||
|
userId: string,
|
||||||
|
snapshot: TSnapshot,
|
||||||
|
): void {
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
try {
|
||||||
|
safeSet(
|
||||||
|
storage,
|
||||||
|
cacheKey(userId),
|
||||||
|
JSON.stringify({
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
snapshot,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore quota/storage write errors in UX cache layer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDashboardSnapshotCache(userId: string): void {
|
||||||
|
const storage = getLocalStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
safeRemove(storage, cacheKey(userId));
|
||||||
|
}
|
||||||
129
tests/lib/credits-activity.test.ts
Normal file
129
tests/lib/credits-activity.test.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildCreditsActivitySeries,
|
||||||
|
calculateUsageActivityDomain,
|
||||||
|
formatCredits,
|
||||||
|
prioritizeRecentCreditTransactions,
|
||||||
|
} from "@/lib/credits-activity";
|
||||||
|
|
||||||
|
type TestTransaction = {
|
||||||
|
_id: string;
|
||||||
|
_creationTime: number;
|
||||||
|
amount: number;
|
||||||
|
type: "subscription" | "topup" | "usage" | "reservation" | "refund";
|
||||||
|
status: "committed" | "reserved" | "released" | "failed";
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("credits activity helpers", () => {
|
||||||
|
it("formats credits as Cr label", () => {
|
||||||
|
expect(formatCredits(1234, "de-DE")).toBe("1.234 Cr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes usage events ahead of non-usage items", () => {
|
||||||
|
const now = Date.UTC(2026, 3, 8, 10, 0, 0);
|
||||||
|
const tx: TestTransaction[] = [
|
||||||
|
{
|
||||||
|
_id: "topup-1",
|
||||||
|
_creationTime: now,
|
||||||
|
amount: 500,
|
||||||
|
type: "topup",
|
||||||
|
status: "committed",
|
||||||
|
description: "Top-up",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "usage-1",
|
||||||
|
_creationTime: now - 60_000,
|
||||||
|
amount: -52,
|
||||||
|
type: "usage",
|
||||||
|
status: "committed",
|
||||||
|
description: "Image generation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "reservation-1",
|
||||||
|
_creationTime: now - 120_000,
|
||||||
|
amount: -52,
|
||||||
|
type: "reservation",
|
||||||
|
status: "reserved",
|
||||||
|
description: "Video reservation",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = prioritizeRecentCreditTransactions(tx, 3);
|
||||||
|
|
||||||
|
expect(result.map((item) => item._id)).toEqual([
|
||||||
|
"usage-1",
|
||||||
|
"reservation-1",
|
||||||
|
"topup-1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a daily activity series with usage and available line", () => {
|
||||||
|
const dayA = Date.UTC(2026, 3, 6, 8, 0, 0);
|
||||||
|
const dayB = Date.UTC(2026, 3, 7, 10, 0, 0);
|
||||||
|
|
||||||
|
const tx: TestTransaction[] = [
|
||||||
|
{
|
||||||
|
_id: "usage-a",
|
||||||
|
_creationTime: dayA,
|
||||||
|
amount: -40,
|
||||||
|
type: "usage",
|
||||||
|
status: "committed",
|
||||||
|
description: "Image",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "reservation-b",
|
||||||
|
_creationTime: dayB,
|
||||||
|
amount: -30,
|
||||||
|
type: "reservation",
|
||||||
|
status: "reserved",
|
||||||
|
description: "Video queued",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "usage-b",
|
||||||
|
_creationTime: dayB + 1_000,
|
||||||
|
amount: -60,
|
||||||
|
type: "usage",
|
||||||
|
status: "committed",
|
||||||
|
description: "Video done",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildCreditsActivitySeries(tx, 320, "de-DE", 2);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
day: "06. Apr",
|
||||||
|
usage: 40,
|
||||||
|
activity: 40,
|
||||||
|
available: 320,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "07. Apr",
|
||||||
|
usage: 60,
|
||||||
|
activity: 90,
|
||||||
|
available: 320,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates a zoomed domain for low usage/activity values", () => {
|
||||||
|
const domain = calculateUsageActivityDomain([
|
||||||
|
{
|
||||||
|
day: "06. Apr",
|
||||||
|
usage: 4,
|
||||||
|
activity: 4,
|
||||||
|
available: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "07. Apr",
|
||||||
|
usage: 20,
|
||||||
|
activity: 20,
|
||||||
|
available: 1200,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(domain).toEqual([0, 24]);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
tests/lib/dashboard-snapshot-cache.test.ts
Normal file
73
tests/lib/dashboard-snapshot-cache.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearDashboardSnapshotCache,
|
||||||
|
readDashboardSnapshotCache,
|
||||||
|
writeDashboardSnapshotCache,
|
||||||
|
} from "@/lib/dashboard-snapshot-cache";
|
||||||
|
|
||||||
|
const USER_ID = "user-cache-test";
|
||||||
|
|
||||||
|
describe("dashboard snapshot cache", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const data = new Map<string, string>();
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: (key: string) => data.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
data.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
data.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: localStorageMock,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearDashboardSnapshotCache(USER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads back a written snapshot", () => {
|
||||||
|
const snapshot = {
|
||||||
|
balance: { available: 320 },
|
||||||
|
generatedAt: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
writeDashboardSnapshotCache(USER_ID, snapshot);
|
||||||
|
const cached = readDashboardSnapshotCache<typeof snapshot>(USER_ID);
|
||||||
|
|
||||||
|
expect(cached?.snapshot).toEqual(snapshot);
|
||||||
|
expect(typeof cached?.cachedAt).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates stale cache entries via ttl", () => {
|
||||||
|
const snapshot = {
|
||||||
|
balance: { available: 100 },
|
||||||
|
generatedAt: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
writeDashboardSnapshotCache(USER_ID, snapshot);
|
||||||
|
const fresh = readDashboardSnapshotCache<typeof snapshot>(USER_ID, {
|
||||||
|
now: Date.now(),
|
||||||
|
ttlMs: 60_000,
|
||||||
|
});
|
||||||
|
expect(fresh?.snapshot).toEqual(snapshot);
|
||||||
|
|
||||||
|
const stale = readDashboardSnapshotCache<typeof snapshot>(USER_ID, {
|
||||||
|
now: Date.now() + 61_000,
|
||||||
|
ttlMs: 60_000,
|
||||||
|
});
|
||||||
|
expect(stale).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears user cache explicitly", () => {
|
||||||
|
writeDashboardSnapshotCache(USER_ID, { generatedAt: 1 });
|
||||||
|
clearDashboardSnapshotCache(USER_ID);
|
||||||
|
|
||||||
|
expect(readDashboardSnapshotCache(USER_ID)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user