merge(feature/dashboard): credits analytics and dashboard snapshot cache

This commit is contained in:
Matthias
2026-04-08 12:45:47 +02:00
12 changed files with 826 additions and 40 deletions

View File

@@ -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>

View File

@@ -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

View 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>
);
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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
View 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(),
};
},
});

View 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
View 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)];
}

View 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));
}

View 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]);
});
});

View 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();
});
});