diff --git a/app/dashboard/page-client.tsx b/app/dashboard/page-client.tsx index 9cb5222..5025a4c 100644 --- a/app/dashboard/page-client.tsx +++ b/app/dashboard/page-client.tsx @@ -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() { Credit-Übersicht - +
@@ -238,8 +241,12 @@ export function DashboardPageClient() { )}
-
- +
+ +
diff --git a/components/dashboard/credit-overview.tsx b/components/dashboard/credit-overview.tsx index c7c31cd..ad017db 100644 --- a/components/dashboard/credit-overview.tsx +++ b/components/dashboard/credit-overview.tsx @@ -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 = { 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() {

Verfügbare Credits

- - {formatEurFromCents(balance.available)} - + + {formatCredits(balance.available, locale)} + {balance.reserved > 0 && (

- ({formatEurFromCents(balance.reserved)} reserviert) + ({formatCredits(balance.reserved, locale)} reserviert)

)}
@@ -132,8 +132,8 @@ export function CreditOverview() { />
- {formatEurFromCents(usageStats.monthlyUsage)} von{" "} - {formatEurFromCents(monthlyCredits)} verwendet + {formatCredits(usageStats.monthlyUsage, locale)} von{" "} + {formatCredits(monthlyCredits, locale)} verwendet {usageStats.totalGenerations} Generierungen diff --git a/components/dashboard/credits-activity-chart.tsx b/components/dashboard/credits-activity-chart.tsx new file mode 100644 index 0000000..e6f1985 --- /dev/null +++ b/components/dashboard/credits-activity-chart.tsx @@ -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 ( +
+
+
+ ); + } + + if (chartData.length === 0) { + return ( +
+
+ + Credits Verlauf +
+

Noch keine verbrauchsrelevante Aktivität vorhanden.

+
+ ); + } + + return ( +
+
+
+ + Credits Verlauf +
+ + Verfügbar: {formatCredits(balance.available, locale)} + +
+ + + + + + + + formatCredits(Number(value), locale)} + /> + } + /> + + + + } /> + + +
+ ); +} diff --git a/components/dashboard/recent-transactions.tsx b/components/dashboard/recent-transactions.tsx index 70fd0c8..d0ba77d 100644 --- a/components/dashboard/recent-transactions.tsx +++ b/components/dashboard/recent-transactions.tsx @@ -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(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 (
@@ -173,7 +174,7 @@ export function RecentTransactions() { )} > {isCredit ? "+" : "−"} - {formatEurFromCents(Math.abs(t.amount))} + {formatCredits(Math.abs(t.amount), locale)}
diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index fd12472..77559b8 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; diff --git a/convex/credits.ts b/convex/credits.ts index 5644adc..dcf3d1d 100644 --- a/convex/credits.ts +++ b/convex/credits.ts @@ -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); }, }); diff --git a/convex/dashboard.ts b/convex/dashboard.ts new file mode 100644 index 0000000..054aa85 --- /dev/null +++ b/convex/dashboard.ts @@ -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(), + }; + }, +}); diff --git a/hooks/use-dashboard-snapshot.ts b/hooks/use-dashboard-snapshot.ts new file mode 100644 index 0000000..abd89eb --- /dev/null +++ b/hooks/use-dashboard-snapshot.ts @@ -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; + +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(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]); +} diff --git a/lib/credits-activity.ts b/lib/credits-activity.ts new file mode 100644 index 0000000..c2f8817 --- /dev/null +++ b/lib/credits-activity.ts @@ -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 = { + usage: 0, + reservation: 1, + refund: 2, + topup: 3, + subscription: 4, +}; + +const STATUS_PRIORITY: Record = { + 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( + 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( + 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[], +): [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)]; +} diff --git a/lib/dashboard-snapshot-cache.ts b/lib/dashboard-snapshot-cache.ts new file mode 100644 index 0000000..7bc2411 --- /dev/null +++ b/lib/dashboard-snapshot-cache.ts @@ -0,0 +1,122 @@ +const STORAGE_NAMESPACE = "lemonspace.dashboard"; +const CACHE_VERSION = 1; +const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000; + +type JsonRecord = Record; + +type DashboardSnapshotCachePayload = { + 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( + userId: string, + options?: { now?: number; ttlMs?: number }, +): DashboardSnapshotCachePayload | 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( + 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)); +} diff --git a/tests/lib/credits-activity.test.ts b/tests/lib/credits-activity.test.ts new file mode 100644 index 0000000..89e5c5f --- /dev/null +++ b/tests/lib/credits-activity.test.ts @@ -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]); + }); +}); diff --git a/tests/lib/dashboard-snapshot-cache.test.ts b/tests/lib/dashboard-snapshot-cache.test.ts new file mode 100644 index 0000000..ce67fea --- /dev/null +++ b/tests/lib/dashboard-snapshot-cache.test.ts @@ -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(); + 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(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(USER_ID, { + now: Date.now(), + ttlMs: 60_000, + }); + expect(fresh?.snapshot).toEqual(snapshot); + + const stale = readDashboardSnapshotCache(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(); + }); +});