feat(dashboard): cache snapshot data and add credits activity analytics
This commit is contained in:
@@ -33,10 +33,11 @@ import { api } from "@/convex/_generated/api";
|
||||
import type { Doc } from "@/convex/_generated/dataModel";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { CreditOverview } from "@/components/dashboard/credit-overview";
|
||||
import { CreditsActivityChart } from "@/components/dashboard/credits-activity-chart";
|
||||
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
|
||||
import CanvasCard from "@/components/dashboard/canvas-card";
|
||||
import { useDashboardSnapshot } from "@/hooks/use-dashboard-snapshot";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
|
||||
function getInitials(nameOrEmail: string) {
|
||||
const normalized = nameOrEmail.trim();
|
||||
@@ -56,10 +57,7 @@ export function DashboardPageClient() {
|
||||
const welcomeToastSentRef = useRef(false);
|
||||
const { theme = "system", setTheme } = useTheme();
|
||||
const { data: session, isPending: isSessionPending } = authClient.useSession();
|
||||
const canvases = useAuthQuery(
|
||||
api.canvases.list,
|
||||
session?.user && !isSessionPending ? {} : "skip",
|
||||
);
|
||||
const { snapshot: dashboardSnapshot } = useDashboardSnapshot(session?.user?.id);
|
||||
const createCanvas = useMutation(api.canvases.create);
|
||||
const [isCreatingWorkspace, setIsCreatingWorkspace] = 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 initials = getInitials(displayName);
|
||||
const canvases = dashboardSnapshot?.canvases;
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user || welcomeToastSentRef.current) return;
|
||||
@@ -190,7 +189,11 @@ export function DashboardPageClient() {
|
||||
<Coins className="size-3.5 text-muted-foreground" />
|
||||
Credit-Übersicht
|
||||
</div>
|
||||
<CreditOverview />
|
||||
<CreditOverview
|
||||
balance={dashboardSnapshot?.balance}
|
||||
subscription={dashboardSnapshot?.subscription}
|
||||
usageStats={dashboardSnapshot?.usageStats}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
@@ -238,8 +241,12 @@ export function DashboardPageClient() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<RecentTransactions />
|
||||
<section className="mb-12 grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] [&>*]:min-w-0">
|
||||
<CreditsActivityChart
|
||||
balance={dashboardSnapshot?.balance}
|
||||
recentTransactions={dashboardSnapshot?.recentTransactions}
|
||||
/>
|
||||
<RecentTransactions recentTransactions={dashboardSnapshot?.recentTransactions} />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
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 canvases from "../canvases.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 export_ from "../export.js";
|
||||
import type * as freepik from "../freepik.js";
|
||||
@@ -48,6 +49,7 @@ declare const fullApi: ApiFromModules<{
|
||||
batch_validation_utils: typeof batch_validation_utils;
|
||||
canvases: typeof canvases;
|
||||
credits: typeof credits;
|
||||
dashboard: typeof dashboard;
|
||||
edges: typeof edges;
|
||||
export: typeof export_;
|
||||
freepik: typeof freepik;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { v, ConvexError } from "convex/values";
|
||||
import { optionalAuth, requireAuth } from "./helpers";
|
||||
import { internal } from "./_generated/api";
|
||||
import { MONTHLY_TIER_CREDITS, normalizeBillingTier } from "../lib/tier-credits";
|
||||
import { prioritizeRecentCreditTransactions } from "../lib/credits-activity";
|
||||
|
||||
// ============================================================================
|
||||
// Tier-Konfiguration
|
||||
@@ -239,12 +240,15 @@ export const getRecentTransactions = query({
|
||||
return [];
|
||||
}
|
||||
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")
|
||||
.withIndex("by_user", (q) => q.eq("userId", user.userId))
|
||||
.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));
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-resizable-panels": "^4.8.0",
|
||||
"recharts": "^3.4.1",
|
||||
"resend": "^4.8.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -95,6 +95,9 @@ importers:
|
||||
react-resizable-panels:
|
||||
specifier: ^4.8.0
|
||||
version: 4.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
recharts:
|
||||
specifier: ^3.4.1
|
||||
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1)
|
||||
resend:
|
||||
specifier: ^4.8.0
|
||||
version: 4.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
||||
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