diff --git a/frontend/src/pages/GameLobby.tsx b/frontend/src/pages/GameLobby.tsx
index 6c5a116d..6122ff9b 100644
--- a/frontend/src/pages/GameLobby.tsx
+++ b/frontend/src/pages/GameLobby.tsx
@@ -19,6 +19,8 @@ import { ActionToolbar, type ToolbarAction } from "../components/v1/ActionToolba
import { InlineStatDelta } from "../components/v1/InlineStatDelta";
import { QueueHealthWidget } from "../components/v1/QueueHealthWidget";
import { QueueStateMiniPanel } from "../components/v1/QueueStateMiniPanel";
+import { CollapsibleStatsGroup } from "../components/v1/CollapsibleStatsGroup";
+import { RewardBalanceSparklineCard } from "../components/v1/RewardBalanceSparklineCard";
import { useWalletStatus } from "../hooks/v1/useWalletStatus";
import { ApiClient } from "../services/typed-api-sdk";
import GlobalStateStore, {
@@ -617,6 +619,100 @@ export const GameLobby: React.FC = () => {
}),
[activeGames.length, lastGamesSyncAt],
);
+ const compactLobbyStats = useMemo(
+ () => [
+ {
+ id: "active-games",
+ label: "Active games",
+ value: activeGames.length,
+ trend: activeGamesDelta === null ? undefined : activeGamesDelta >= 0 ? ("up" as const) : ("down" as const),
+ change:
+ activeGamesDelta === null
+ ? undefined
+ : `${activeGamesDelta > 0 ? "+" : ""}${activeGamesDelta}`,
+ caption: "Compared with previous sync",
+ },
+ {
+ id: "wallet-ready",
+ label: "Wallet readiness",
+ value: wallet.capabilities.isConnected ? "Connected" : "Not connected",
+ caption: wallet.network ?? "No active network",
+ },
+ {
+ id: "pending-actions",
+ label: "Pending actions",
+ value: pendingTransaction ? 1 : 0,
+ caption: pendingTransaction ? "Resume from tx status panel" : "No pending wallet tx",
+ },
+ {
+ id: "prize-signal",
+ label: "Prize signal",
+ value: `${totalPrizeSignal.toFixed(0)} XLM`,
+ },
+ ],
+ [
+ activeGames.length,
+ activeGamesDelta,
+ pendingTransaction,
+ totalPrizeSignal,
+ wallet.capabilities.isConnected,
+ wallet.network,
+ ],
+ );
+ const rewardTrendCards = useMemo(
+ () => {
+ if (games.length === 0) {
+ return [];
+ }
+ const reserveSeries = [
+ Math.max(totalPrizeSignal * 0.72, 0),
+ Math.max(totalPrizeSignal * 0.8, 0),
+ Math.max(totalPrizeSignal * 0.88, 0),
+ totalPrizeSignal,
+ ];
+ const participationSeries = [
+ Math.max(activeGames.length - 1, 0),
+ activeGames.length,
+ activeGames.length + (activeGamesDelta && activeGamesDelta > 0 ? 1 : 0),
+ activeGames.length,
+ ];
+ return [
+ {
+ id: "prize-reserve",
+ label: "Prize Reserve",
+ balance: `${totalPrizeSignal.toFixed(0)} XLM`,
+ balanceEquivalent: `${activeGames.length} live table${activeGames.length === 1 ? "" : "s"}`,
+ dataPoints: reserveSeries,
+ change:
+ activeGamesDelta === null
+ ? "0%"
+ : `${activeGamesDelta > 0 ? "+" : ""}${Math.min(Math.abs(activeGamesDelta) * 5, 25)}%`,
+ trend: activeGamesDelta !== null && activeGamesDelta < 0 ? ("down" as const) : ("up" as const),
+ },
+ {
+ id: "arena-participation",
+ label: "Arena Participation",
+ balance: `${activeGames.length * 4} players`,
+ balanceEquivalent: "Estimated queued + active players",
+ dataPoints: participationSeries,
+ change: queueSummaryMetrics.queueHealth === "healthy" ? "+8%" : "0%",
+ trend:
+ queueSummaryMetrics.queueHealth === "healthy"
+ ? ("up" as const)
+ : queueSummaryMetrics.queueHealth === "degraded"
+ ? ("flat" as const)
+ : ("down" as const),
+ },
+ ];
+ },
+ [
+ activeGames.length,
+ activeGamesDelta,
+ games.length,
+ queueSummaryMetrics.queueHealth,
+ totalPrizeSignal,
+ ],
+ );
const activityItems = useMemo(() => {
const items: Array<{
@@ -807,6 +903,35 @@ export const GameLobby: React.FC = () => {
onRefresh={handleRefreshLobby}
testId="lobby-queue-summary"
/>
+
+ {rewardTrendCards.length === 0 ? (
+
+ ) : (
+ rewardTrendCards.map((card) => (
+
+ ))
+ )}
+
+
{pendingResumeContext ? (
{
{ id: 'wallet-review', label: 'I verified wallet ownership and profile identity.' },
];
const isReviewComplete = checkedReviewIds.length === reviewChecklist.length;
+ const profileSummaryStats = useMemo(
+ () => [
+ {
+ id: 'wallet-connected',
+ label: 'Wallet',
+ value: walletMeta.connected ? 'Connected' : 'Disconnected',
+ caption: walletMeta.address,
+ },
+ {
+ id: 'network',
+ label: 'Network',
+ value: walletMeta.network,
+ },
+ {
+ id: 'provider',
+ label: 'Provider',
+ value: walletMeta.provider,
+ },
+ {
+ id: 'draft-state',
+ label: 'Draft state',
+ value: draftStatus,
+ caption: hasDraftChanges ? 'Unsaved updates present' : 'No pending profile edits',
+ },
+ ],
+ [draftStatus, hasDraftChanges, walletMeta],
+ );
+ const rewardTrendStatus = loading ? 'loading' : error ? 'error' : profile ? 'idle' : 'idle';
+ const primaryRewardSeries = useMemo(
+ () => {
+ const seed = (profile?.username?.length ?? 2) + (walletMeta.connected ? 3 : 1);
+ return [seed, seed + 1, seed + 2, seed + 1, seed + 3];
+ },
+ [profile?.username?.length, walletMeta.connected],
+ );
+ const bonusRewardSeries = useMemo(
+ () => {
+ const base = walletMeta.connected ? 8 : 3;
+ return [base, base + 1, base + 1, base + 2];
+ },
+ [walletMeta.connected],
+ );
useEffect(() => {
if (hasDraftChanges) {
@@ -267,6 +311,47 @@ const ProfileSettings: React.FC = () => {
Wallet
+
+ undefined}
+ balance={profile ? `${(profile.username?.length ?? 0) * 12} XP` : undefined}
+ balanceEquivalent={profile ? `Profile: ${profile.username || 'Unnamed'}` : undefined}
+ dataPoints={primaryRewardSeries}
+ change={walletMeta.connected ? '+6%' : '0%'}
+ trend={walletMeta.connected ? 'up' : 'flat'}
+ testId="profile-settings-reward-earned"
+ />
+ undefined}
+ balance={walletMeta.connected ? 'Ready' : undefined}
+ balanceEquivalent={walletMeta.connected ? walletMeta.network : undefined}
+ dataPoints={bonusRewardSeries}
+ change={walletMeta.connected ? '+3%' : '0%'}
+ trend={walletMeta.connected ? 'up' : 'down'}
+ testId="profile-settings-reward-bonus"
+ />
+
+
{
"neutral",
);
});
+
+ it("renders compact expandable stats and reward trend fallback when no games are loaded", async () => {
+ (ApiClient as any).prototype.getGames.mockResolvedValue({
+ success: true,
+ data: [],
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("lobby-compact-stats")).toBeInTheDocument();
+ expect(screen.getByTestId("lobby-reward-trend-loading")).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTestId("lobby-compact-stats-toggle"));
+ expect(screen.getByTestId("lobby-compact-stats-panel")).not.toHaveAttribute("hidden");
+ });
});
diff --git a/frontend/tests/components/ProfileSettings.test.tsx b/frontend/tests/components/ProfileSettings.test.tsx
index 5d75f767..a44385d9 100644
--- a/frontend/tests/components/ProfileSettings.test.tsx
+++ b/frontend/tests/components/ProfileSettings.test.tsx
@@ -2,20 +2,22 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ProfileSettings, { profileStore } from '@/pages/ProfileSettings';
+const walletStatusState = vi.hoisted((): any => ({
+ address: 'GTEST1234567890',
+ network: 'TESTNET',
+ provider: 'WalletProvider',
+ capabilities: { isConnected: true },
+ status: 'connected',
+ error: null,
+ lastUpdatedAt: Date.now(),
+ refresh: vi.fn(),
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ isRefreshing: false,
+}));
+
vi.mock('@/hooks/v1/useWalletStatus', () => ({
- useWalletStatus: () => ({
- address: 'GTEST1234567890',
- network: 'TESTNET',
- provider: 'WalletProvider',
- capabilities: { isConnected: true },
- status: 'connected',
- error: null,
- lastUpdatedAt: Date.now(),
- refresh: vi.fn(),
- connect: vi.fn(),
- disconnect: vi.fn(),
- isRefreshing: false,
- }),
+ useWalletStatus: () => walletStatusState,
}));
const mockGetProfile = vi.fn();
@@ -35,6 +37,13 @@ vi.mock('@/services/typed-api-sdk', () => ({
describe('ProfileSettings page', () => {
beforeEach(() => {
vi.clearAllMocks();
+ walletStatusState.address = 'GTEST1234567890';
+ walletStatusState.network = 'TESTNET';
+ walletStatusState.provider = 'WalletProvider';
+ walletStatusState.capabilities = { isConnected: true };
+ walletStatusState.status = 'connected';
+ walletStatusState.error = null;
+ walletStatusState.lastUpdatedAt = Date.now();
profileStore.dispatch({ type: 'AUTH_SET', payload: { userId: 'test', token: 'test-jwt-token' } });
profileStore.dispatch({ type: 'PROFILE_CLEAR' });
});
@@ -79,6 +88,37 @@ describe('ProfileSettings page', () => {
expect(
screen.getByTestId('profile-settings-actions-footer'),
).toBeInTheDocument();
+ expect(
+ screen.getByTestId('profile-settings-reward-trend-grid'),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId('profile-settings-overview-stats'),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('renders mini reward trend cards with empty fallback when wallet is disconnected', async () => {
+ mockGetProfile.mockResolvedValueOnce({
+ success: true,
+ data: {
+ address: 'GABC123',
+ username: 'alice',
+ createdAt: '2025-01-01T12:00:00Z',
+ },
+ });
+
+ walletStatusState.address = null;
+ walletStatusState.network = null;
+ walletStatusState.provider = null;
+ walletStatusState.capabilities = { isConnected: false };
+ walletStatusState.status = 'disconnected';
+ walletStatusState.lastUpdatedAt = null;
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('profile-settings-reward-earned')).toBeInTheDocument();
+ expect(screen.getByTestId('profile-settings-reward-bonus-empty')).toBeInTheDocument();
});
});