diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index 5ceac82..f173a64 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -10,6 +10,8 @@ use soroban_sdk::{contracttype, token, Address, BytesN, Env, String, Vec}; pub enum DataKey { AutoShare(BytesN<32>), AllGroups, + /// Indexed group IDs owned by a creator — avoids full AllGroups scans for dashboard queries. + CreatorGroups(Address), Admin, SupportedTokens, UsageFee, @@ -82,6 +84,18 @@ pub fn create_autoshare( all_groups.push_back(id.clone()); env.storage().persistent().set(&all_groups_key, &all_groups); + // Maintain per-creator index for efficient dashboard lookups + let creator_groups_key = DataKey::CreatorGroups(creator.clone()); + let mut creator_groups: Vec> = env + .storage() + .persistent() + .get(&creator_groups_key) + .unwrap_or(Vec::new(&env)); + creator_groups.push_back(id.clone()); + env.storage() + .persistent() + .set(&creator_groups_key, &creator_groups); + // Initialize empty members list let members_key = DataKey::GroupMembers(id.clone()); let empty_members: Vec = Vec::new(&env); @@ -127,12 +141,18 @@ pub fn get_all_groups(env: Env) -> Vec { } pub fn get_groups_by_creator(env: Env, creator: Address) -> Vec { - let all_groups = get_all_groups(env.clone()); - let mut result: Vec = Vec::new(&env); + // Use the creator index instead of scanning every group (O(creator) vs O(all)). + let creator_groups_key = DataKey::CreatorGroups(creator); + let group_ids: Vec> = env + .storage() + .persistent() + .get(&creator_groups_key) + .unwrap_or(Vec::new(&env)); - for group in all_groups.iter() { - if group.creator == creator { - result.push_back(group); + let mut result: Vec = Vec::new(&env); + for id in group_ids.iter() { + if let Ok(details) = get_autoshare(env.clone(), id) { + result.push_back(details); } } result diff --git a/contract/contracts/hello-world/src/tests/autoshare_test.rs b/contract/contracts/hello-world/src/tests/autoshare_test.rs index 22b08c7..fa70a06 100644 --- a/contract/contracts/hello-world/src/tests/autoshare_test.rs +++ b/contract/contracts/hello-world/src/tests/autoshare_test.rs @@ -192,7 +192,7 @@ fn test_get_groups_by_creator_multiple() { 1, &token, ); - let _id2 = create_test_group( + let id2 = create_test_group( &test_env.env, &test_env.autoshare_contract, &creator2, @@ -209,10 +209,15 @@ fn test_get_groups_by_creator_multiple() { &token, ); - let groups = client.get_groups_by_creator(&creator1); + let mut groups = client.get_groups_by_creator(&creator1); assert_eq!(groups.len(), 2); assert_eq!(groups.get(0).unwrap().id, id1); assert_eq!(groups.get(1).unwrap().id, id3); + + // Creator index must ignore other creators' groups (no full-table scan semantics). + groups = client.get_groups_by_creator(&creator2); + assert_eq!(groups.len(), 1); + assert_eq!(groups.get(0).unwrap().id, id2); } #[test] diff --git a/frontend/components.json b/frontend/components.json index bc68876..b2dffe2 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -11,8 +11,8 @@ "prefix": "" }, "aliases": { - "components": "@/src/components", - "utils": "@/src/lib/utils", - "ui": "@/src/components/ui" + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui" } } diff --git a/frontend/src/app/(dashboard)/user/overview/components/ActiveGroupsWidget.tsx b/frontend/src/app/(dashboard)/user/overview/components/ActiveGroupsWidget.tsx index 8120b60..93bd9c5 100644 --- a/frontend/src/app/(dashboard)/user/overview/components/ActiveGroupsWidget.tsx +++ b/frontend/src/app/(dashboard)/user/overview/components/ActiveGroupsWidget.tsx @@ -1,84 +1,12 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { motion } from "framer-motion"; import { Users, TrendingUp, ChevronRight, Wallet } from "lucide-react"; import Image from "next/image"; -export type ActiveGroup = { - id: string; - name: string; - totalFunds: number; - members: number; - activity: "high" | "medium" | "low"; - recentTransactions: number; - tokens: Array<{ name: string; icon: string; amount: string }>; -}; - -const MOCK_ACTIVE_GROUPS: ActiveGroup[] = [ - { - id: "1", - name: "Paymesh Core", - totalFunds: 24500, - members: 8, - activity: "high", - recentTransactions: 42, - tokens: [ - { name: "wBTC", icon: "/coin/Image (3).png", amount: "0.15" }, - { name: "ETH", icon: "/coin/Image (4).png", amount: "3.2" }, - { name: "USDC", icon: "/coin/Image (1).png", amount: "8,500" }, - ], - }, - { - id: "2", - name: "TechFlow DAO", - totalFunds: 18750, - members: 12, - activity: "high", - recentTransactions: 36, - tokens: [ - { name: "ETH", icon: "/coin/Image (4).png", amount: "5.1" }, - { name: "USDT", icon: "/coin/Image.png", amount: "6,200" }, - { name: "XLM", icon: "/Stellar.png", amount: "15,000" }, - ], - }, - { - id: "3", - name: "Greenwave Fund", - totalFunds: 12300, - members: 5, - activity: "medium", - recentTransactions: 19, - tokens: [ - { name: "USDC", icon: "/coin/Image (1).png", amount: "9,800" }, - { name: "XLM", icon: "/Stellar.png", amount: "8,000" }, - ], - }, - { - id: "4", - name: "CryptoNest", - totalFunds: 8900, - members: 4, - activity: "medium", - recentTransactions: 14, - tokens: [ - { name: "wBTC", icon: "/coin/Image (3).png", amount: "0.05" }, - { name: "USDT", icon: "/coin/Image.png", amount: "4,200" }, - ], - }, - { - id: "5", - name: "DataPulse", - totalFunds: 5600, - members: 6, - activity: "low", - recentTransactions: 7, - tokens: [ - { name: "ETH", icon: "/coin/Image (4).png", amount: "1.8" }, - { name: "USDC", icon: "/coin/Image (1).png", amount: "1,200" }, - ], - }, -]; +import type { DashboardGroup } from "@/constants/dashboard-groups"; +import type { DashboardStatistics } from "@/lib/dashboard-stats"; const activityConfig = { high: { @@ -142,9 +70,17 @@ const itemVariants = { }, }; -function GroupRow({ group, index, maxFunds }: { group: ActiveGroup; index: number; maxFunds: number }) { +function GroupRow({ + group, + index, + maxFunds, +}: { + group: DashboardGroup; + index: number; + maxFunds: number; +}) { const activity = activityConfig[group.activity]; - const fundPercentage = (group.totalFunds / maxFunds) * 100; + const fundPercentage = maxFunds > 0 ? (group.totalFunds / maxFunds) * 100 : 0; return ( - {/* Rank Badge */}
#{index + 1}
- {/* Group Info */}

@@ -171,7 +105,6 @@ function GroupRow({ group, index, maxFunds }: { group: ActiveGroup; index: numbe

- {/* Progress Bar */}
- {/* Bottom Details */}
@@ -191,11 +123,10 @@ function GroupRow({ group, index, maxFunds }: { group: ActiveGroup; index: numbe {group.recentTransactions} txns - {/* Token Avatars */}
{group.tokens.slice(0, 3).map((token, i) => (
- {/* Total Funds */}

{formatCurrency(group.totalFunds)} @@ -224,17 +154,60 @@ function GroupRow({ group, index, maxFunds }: { group: ActiveGroup; index: numbe

total funds

- {/* Chevron */} ); } export default function ActiveGroupsWidget() { - const groups = MOCK_ACTIVE_GROUPS; - const maxFunds = Math.max(...groups.map((g) => g.totalFunds)); - const totalAllFunds = groups.reduce((sum, g) => sum + g.totalFunds, 0); - const totalMembers = groups.reduce((sum, g) => sum + g.members, 0); + const [stats, setStats] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function loadStats() { + try { + const response = await fetch("/api/dashboard/stats", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Failed to load dashboard stats (${response.status})`); + } + + const body = (await response.json()) as { + ok: boolean; + stats: DashboardStatistics; + }; + + if (!body.ok) { + throw new Error("Dashboard stats response was not successful"); + } + + if (!cancelled) { + setStats(body.stats); + setError(null); + } + } catch (loadError) { + if (!cancelled) { + setError( + loadError instanceof Error + ? loadError.message + : "Failed to load dashboard statistics", + ); + } + } + } + + void loadStats(); + + return () => { + cancelled = true; + }; + }, []); + + const groups = stats?.groups ?? []; + const maxFunds = stats?.maxFunds ?? 0; + const totalAllFunds = stats?.totalFunds ?? 0; + const totalMembers = stats?.totalMembers ?? 0; return (
- {/* Header */}
@@ -265,14 +237,13 @@ export default function ActiveGroupsWidget() {
- {/* Summary Stats Row */}

Total Funds

- {formatCurrency(totalAllFunds)} + {stats ? formatCurrency(totalAllFunds) : "—"}

@@ -280,7 +251,7 @@ export default function ActiveGroupsWidget() { Active Groups

- {groups.length} + {stats ? groups.length : "—"}

@@ -288,13 +259,12 @@ export default function ActiveGroupsWidget() { Members

- {totalMembers} + {stats ? totalMembers : "—"}

- {/* Groups List */} - {groups.map((group, index) => ( - - ))} + {error ? ( +

{error}

+ ) : !stats ? ( +

Loading groups…

+ ) : ( + groups.map((group, index) => ( + + )) + )}
- {/* Footer */}

- Showing top {groups.length} groups by activity • Updated just now + {stats + ? `Showing top ${groups.length} groups by activity • Updated just now` + : "Loading dashboard statistics…"}

diff --git a/frontend/src/app/api/dashboard/stats/route.ts b/frontend/src/app/api/dashboard/stats/route.ts new file mode 100644 index 0000000..c3704c0 --- /dev/null +++ b/frontend/src/app/api/dashboard/stats/route.ts @@ -0,0 +1,22 @@ +import { getDashboardStatistics } from "@/lib/dashboard-stats"; +import { buildNoStoreJson } from "@/lib/api-response"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Returns dashboard overview statistics in a single response so the UI does not + * need multiple round-trips for totals, member counts, and group listings. + */ +export async function GET() { + const result = getDashboardStatistics(); + + return buildNoStoreJson( + { + ok: true, + stats: result.stats, + meta: result.meta, + }, + 200, + ); +} diff --git a/frontend/src/app/test-components/page.tsx b/frontend/src/app/test-components/page.tsx index dcc1e07..83e4a89 100644 --- a/frontend/src/app/test-components/page.tsx +++ b/frontend/src/app/test-components/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import React, { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; diff --git a/frontend/src/app/test-shadcn/page.tsx b/frontend/src/app/test-shadcn/page.tsx index 489478f..67232ab 100644 --- a/frontend/src/app/test-shadcn/page.tsx +++ b/frontend/src/app/test-shadcn/page.tsx @@ -1,12 +1,12 @@ "use client" -import { Button } from "@/src/components/ui" -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/src/components/ui" -import { Input } from "@/src/components/ui" -import { Badge } from "@/src/components/ui" -import { Skeleton } from "@/src/components/ui" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/src/components/ui" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/src/components/ui" +import { Button } from "@/components/ui" +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui" +import { Input } from "@/components/ui" +import { Badge } from "@/components/ui" +import { Skeleton } from "@/components/ui" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui" export default function TestComponents() { return ( diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index aae8f8b..beb56ed 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui" -import { cn } from "@/src/lib/utils" +import { cn } from "@/lib/utils" const badgeVariants = cva( "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 7aa462f..b5ea4ab 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui" -import { cn } from "@/src/lib/utils" +import { cn } from "@/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx index c6db0be..681ad98 100644 --- a/frontend/src/components/ui/card.tsx +++ b/frontend/src/components/ui/card.tsx @@ -1,6 +1,6 @@ import * as React from "react" -import { cn } from "@/src/lib/utils" +import { cn } from "@/lib/utils" function Card({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 949a1dc..8916905 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -1,6 +1,6 @@ import * as React from "react" -import { cn } from "@/src/lib/utils" +import { cn } from "@/lib/utils" function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index ed5f6e9..9589ede 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { Select as SelectPrimitive } from "radix-ui" -import { cn } from "@/src/lib/utils" +import { cn } from "@/lib/utils" function Select({ ...props diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx index 9c583d8..32ea0ef 100644 --- a/frontend/src/components/ui/skeleton.tsx +++ b/frontend/src/components/ui/skeleton.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/src/lib/utils" +import { cn } from "@/lib/utils" function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx index 46321da..7f73dcd 100644 --- a/frontend/src/components/ui/tabs.tsx +++ b/frontend/src/components/ui/tabs.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Tabs as TabsPrimitive } from "radix-ui" -import { cn } from "@/src/lib/utils" +import { cn } from "@/lib/utils" function Tabs({ className, diff --git a/frontend/src/constants/dashboard-groups.ts b/frontend/src/constants/dashboard-groups.ts new file mode 100644 index 0000000..7fef85c --- /dev/null +++ b/frontend/src/constants/dashboard-groups.ts @@ -0,0 +1,80 @@ +export type GroupActivity = "high" | "medium" | "low"; + +export type DashboardGroup = { + id: string; + name: string; + totalFunds: number; + members: number; + activity: GroupActivity; + recentTransactions: number; + tokens: Array<{ name: string; icon: string; amount: string }>; +}; + +/** + * Canonical dashboard group records. In production this would be hydrated from + * storage / contract reads; the query layer treats it as the data source. + */ +export const DASHBOARD_GROUPS: DashboardGroup[] = [ + { + id: "1", + name: "Paymesh Core", + totalFunds: 24500, + members: 8, + activity: "high", + recentTransactions: 42, + tokens: [ + { name: "wBTC", icon: "/coin/Image (3).png", amount: "0.15" }, + { name: "ETH", icon: "/coin/Image (4).png", amount: "3.2" }, + { name: "USDC", icon: "/coin/Image (1).png", amount: "8,500" }, + ], + }, + { + id: "2", + name: "TechFlow DAO", + totalFunds: 18750, + members: 12, + activity: "high", + recentTransactions: 36, + tokens: [ + { name: "ETH", icon: "/coin/Image (4).png", amount: "5.1" }, + { name: "USDT", icon: "/coin/Image.png", amount: "6,200" }, + { name: "XLM", icon: "/Stellar.png", amount: "15,000" }, + ], + }, + { + id: "3", + name: "Greenwave Fund", + totalFunds: 12300, + members: 5, + activity: "medium", + recentTransactions: 19, + tokens: [ + { name: "USDC", icon: "/coin/Image (1).png", amount: "9,800" }, + { name: "XLM", icon: "/Stellar.png", amount: "8,000" }, + ], + }, + { + id: "4", + name: "CryptoNest", + totalFunds: 8900, + members: 4, + activity: "medium", + recentTransactions: 14, + tokens: [ + { name: "wBTC", icon: "/coin/Image (3).png", amount: "0.05" }, + { name: "USDT", icon: "/coin/Image.png", amount: "4,200" }, + ], + }, + { + id: "5", + name: "DataPulse", + totalFunds: 5600, + members: 6, + activity: "low", + recentTransactions: 7, + tokens: [ + { name: "ETH", icon: "/coin/Image (4).png", amount: "1.8" }, + { name: "USDC", icon: "/coin/Image (1).png", amount: "1,200" }, + ], + }, +]; diff --git a/frontend/src/lib/dashboard-stats.test.ts b/frontend/src/lib/dashboard-stats.test.ts new file mode 100644 index 0000000..622e0a6 --- /dev/null +++ b/frontend/src/lib/dashboard-stats.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { GET } from "@/app/api/dashboard/stats/route"; +import { DASHBOARD_GROUPS } from "@/constants/dashboard-groups"; +import { + getDashboardStatistics, + getDashboardStatisticsNaive, +} from "@/lib/dashboard-stats"; + +describe("dashboard statistics query optimization", () => { + it("returns the same aggregates for naive and optimized strategies", () => { + const naive = getDashboardStatisticsNaive(); + const optimized = getDashboardStatistics(); + + expect(optimized.stats).toEqual(naive.stats); + expect(optimized.stats.activeGroupCount).toBe(DASHBOARD_GROUPS.length); + expect(optimized.stats.totalFunds).toBe(70050); + expect(optimized.stats.totalMembers).toBe(35); + expect(optimized.stats.maxFunds).toBe(24500); + expect(optimized.stats.totalTransactions).toBe(118); + }); + + it("reduces query count versus the naive multi-pass approach", () => { + const naive = getDashboardStatisticsNaive(); + const optimized = getDashboardStatistics(); + + expect(naive.meta.strategy).toBe("naive"); + expect(optimized.meta.strategy).toBe("optimized"); + expect(optimized.meta.queryCount).toBe(1); + expect(naive.meta.queryCount).toBeGreaterThan(optimized.meta.queryCount); + expect(optimized.meta.queryCount).toBeLessThanOrEqual(1); + }); + + it("preserves group ordering and fields for the overview widget", () => { + const { stats } = getDashboardStatistics(); + + expect(stats.groups).toHaveLength(DASHBOARD_GROUPS.length); + expect(stats.groups[0]).toMatchObject({ + id: "1", + name: "Paymesh Core", + totalFunds: 24500, + members: 8, + activity: "high", + }); + expect(stats.groups.map((group) => group.id)).toEqual( + DASHBOARD_GROUPS.map((group) => group.id), + ); + }); + + it("GET /api/dashboard/stats serves a single aggregated payload", async () => { + const response = await GET(); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + + const body = await response.json(); + + expect(body.ok).toBe(true); + expect(body.meta.queryCount).toBe(1); + expect(body.meta.strategy).toBe("optimized"); + expect(body.stats.activeGroupCount).toBe(5); + expect(body.stats.totalFunds).toBe(70050); + expect(body.stats.groups).toHaveLength(5); + }); +}); diff --git a/frontend/src/lib/dashboard-stats.ts b/frontend/src/lib/dashboard-stats.ts new file mode 100644 index 0000000..4478e0d --- /dev/null +++ b/frontend/src/lib/dashboard-stats.ts @@ -0,0 +1,126 @@ +import { + DASHBOARD_GROUPS, + type DashboardGroup, +} from "@/constants/dashboard-groups"; + +export type DashboardStatistics = { + groups: DashboardGroup[]; + totalFunds: number; + activeGroupCount: number; + totalMembers: number; + maxFunds: number; + totalTransactions: number; +}; + +export type DashboardStatsQueryMeta = { + /** Number of underlying data reads performed to build the response. */ + queryCount: number; + /** Wall-clock milliseconds spent aggregating (best-effort). */ + durationMs: number; + /** Strategy used to load the statistics. */ + strategy: "naive" | "optimized"; +}; + +export type DashboardStatsResult = { + ok: true; + stats: DashboardStatistics; + meta: DashboardStatsQueryMeta; +}; + +/** + * Naive path: separate passes for each metric (simulates multiple DB round-trips). + * Kept for regression comparison — production code should use the optimized path. + */ +export function getDashboardStatisticsNaive( + groups: readonly DashboardGroup[] = DASHBOARD_GROUPS, +): DashboardStatsResult { + const started = performance.now(); + let queryCount = 0; + + // Each reduce / map below models a separate query against storage. + queryCount += 1; + const listed = groups.map((group) => ({ ...group })); + + queryCount += 1; + const totalFunds = listed.reduce((sum, group) => sum + group.totalFunds, 0); + + queryCount += 1; + const activeGroupCount = listed.length; + + queryCount += 1; + const totalMembers = listed.reduce((sum, group) => sum + group.members, 0); + + queryCount += 1; + const maxFunds = listed.reduce( + (max, group) => Math.max(max, group.totalFunds), + 0, + ); + + queryCount += 1; + const totalTransactions = listed.reduce( + (sum, group) => sum + group.recentTransactions, + 0, + ); + + return { + ok: true, + stats: { + groups: listed, + totalFunds, + activeGroupCount, + totalMembers, + maxFunds, + totalTransactions, + }, + meta: { + queryCount, + durationMs: Number((performance.now() - started).toFixed(3)), + strategy: "naive", + }, + }; +} + +/** + * Optimized path: one read of the group set, then a single aggregation pass. + * Reduces query count from 6 → 1 while preserving identical statistics. + */ +export function getDashboardStatistics( + groups: readonly DashboardGroup[] = DASHBOARD_GROUPS, +): DashboardStatsResult { + const started = performance.now(); + + // Single storage read of the dashboard dataset. + const listed = groups.map((group) => ({ ...group })); + const queryCount = 1; + + let totalFunds = 0; + let totalMembers = 0; + let maxFunds = 0; + let totalTransactions = 0; + + for (const group of listed) { + totalFunds += group.totalFunds; + totalMembers += group.members; + totalTransactions += group.recentTransactions; + if (group.totalFunds > maxFunds) { + maxFunds = group.totalFunds; + } + } + + return { + ok: true, + stats: { + groups: listed, + totalFunds, + activeGroupCount: listed.length, + totalMembers, + maxFunds, + totalTransactions, + }, + meta: { + queryCount, + durationMs: Number((performance.now() - started).toFixed(3)), + strategy: "optimized", + }, + }; +}