Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions frontend/src/pages/GameLobby.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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),
Comment on lines +687 to +690
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Search for trend calculations to verify null-delta handling patterns.

rg -nP --type=ts --type=tsx -C3 'trend.*:.*activeGamesDelta'

Repository: theblockcade/stellarcade

Length of output: 96


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant region in GameLobby.tsx
FILE="frontend/src/pages/GameLobby.tsx"
echo "=== Context around rewardTrendCards (lines ~660-720) ==="
nl -ba "$FILE" | sed -n '640,720p'

echo "=== Context around compactLobbyStats (search for compactLobbyStats) ==="
rg -n "compactLobbyStats" "$FILE"
# Print a window around the first match (if any)
MATCH_LINE=$(rg -n "compactLobbyStats" "$FILE" | head -n1 | cut -d: -f1 || true)
if [ -n "${MATCH_LINE:-}" ]; then
  START=$((MATCH_LINE-30))
  END=$((MATCH_LINE+30))
  nl -ba "$FILE" | sed -n "${START},${END}p"
fi

echo "=== Find all uses of activeGamesDelta in this file ==="
rg -n "activeGamesDelta" "$FILE" --heading

echo "=== Search for trend assignments involving activeGamesDelta across TS/TSX ==="
rg -n --glob='*.ts' --glob='*.tsx' -C3 "trend.*activeGamesDelta|activeGamesDelta.*trend" .

Repository: theblockcade/stellarcade

Length of output: 186


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="frontend/src/pages/GameLobby.tsx"

echo "=== Check file exists ==="
ls -l "$FILE"

echo "=== rewardTrendCards region (~630-720) ==="
awk 'NR>=630 && NR<=720 {printf "%d\t%s\n", NR, $0}' "$FILE"

echo "=== Find compactLobbyStats occurrences (with context) ==="
rg -n "compactLobbyStats" "$FILE" -C2

echo "=== compactLobbyStats region around first occurrence(s) ==="
# Print a generous window around each match line
for L in $(rg -n "compactLobbyStats" "$FILE" | cut -d: -f1); do
  START=$((L-80)); END=$((L+80))
  awk -v s="$START" -v e="$END" 'NR>=s && NR<=e {printf "%d\t%s\n", NR, $0}' "$FILE" | sed -n "1,120p"
  echo "----"
done

echo "=== Find all assignments/uses of activeGamesDelta in this file ==="
rg -n "activeGamesDelta" "$FILE" --heading

Repository: theblockcade/stellarcade

Length of output: 19333


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for null-delta handling for activeGamesDelta ==="
rg -n --glob='*.ts' --glob='*.tsx' "activeGamesDelta === null|activeGamesDelta !== null" .

echo "=== Search for trend assignments that involve activeGamesDelta ==="
rg -n --glob='*.ts' --glob='*.tsx' -C3 "trend[^\\n]*activeGamesDelta|trend[^\\n]*activeGamesDelta[^\\n]*:|:.*activeGamesDelta[^\\n]*trend" .

echo "=== Search for specific rewardTrendCards change/trend logic patterns ==="
rg -n --glob='*.ts' --glob='*.tsx' -C3 "activeGamesDelta.*< 0.*\\(\"down\" as const\\)|(\"down\" as const).*activeGamesDelta" .

Repository: theblockcade/stellarcade

Length of output: 2478


Fix prize-reserve trend when activeGamesDelta is null

rewardTrendCards sets change to "0%" when activeGamesDelta === null, but forces trend to "up"; meanwhile compactLobbyStats sets trend to undefined (and change to undefined) for the same activeGamesDelta === null case. Consider mapping activeGamesDelta === null to "flat" or undefined in rewardTrendCards to match the existing behavior in compactLobbyStats (e.g., around GameLobby.tsx lines 687-690 vs 628-633).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/pages/GameLobby.tsx` around lines 687 - 690, rewardTrendCards is
currently setting change="0%" and trend="up" when activeGamesDelta === null,
which conflicts with compactLobbyStats where null maps to undefined (or a "flat"
semantic); update the mapping in rewardTrendCards (the expressions that set
change and trend around activeGamesDelta) so that when activeGamesDelta === null
you set trend to undefined (or "flat") and change to undefined (instead of
"0%"), and keep the existing signed/min calculation only for non-null values
(use the same ternary guards as compactLobbyStats to ensure consistent
behavior).

},
{
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<{
Expand Down Expand Up @@ -807,6 +903,35 @@ export const GameLobby: React.FC = () => {
onRefresh={handleRefreshLobby}
testId="lobby-queue-summary"
/>
<div className="lobby-reward-trend-grid" data-testid="lobby-reward-trend-grid">
{rewardTrendCards.length === 0 ? (
<RewardBalanceSparklineCard
label="Reward trend"
status="loading"
testId="lobby-reward-trend-loading"
/>
) : (
rewardTrendCards.map((card) => (
<RewardBalanceSparklineCard
key={card.id}
label={card.label}
balance={card.balance}
balanceEquivalent={card.balanceEquivalent}
dataPoints={card.dataPoints}
change={card.change}
trend={card.trend}
testId={`lobby-reward-trend-${card.id}`}
/>
))
)}
</div>
<CollapsibleStatsGroup
title="Compact lobby stats"
summary={`${compactLobbyStats.length} metrics`}
items={compactLobbyStats}
defaultExpanded={false}
testId="lobby-compact-stats"
/>

{pendingResumeContext ? (
<ResumeTaskBanner
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/pages/GameLobbyDashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,18 @@
margin-top: 1rem;
}

.lobby-reward-trend-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.85rem;
margin-top: 0.9rem;
}

#lobby-compact-stats,
.lobby-compact-stats {
margin-top: 0.9rem;
}

@media (max-width: 600px) {
.dashboard-mission-strip__header,
.quick-action-surface__header,
Expand All @@ -266,6 +278,10 @@
align-items: flex-start;
}

.lobby-reward-trend-grid {
grid-template-columns: 1fr;
}

.game-lobby--with-mobile-footer {
padding-bottom: calc(104px + env(safe-area-inset-bottom));
}
Expand Down
85 changes: 85 additions & 0 deletions frontend/src/pages/ProfileSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { AccountSwitcher } from '@/components/v1/AccountSwitcher';
import { DraftPresenceIndicator } from '@/components/v1/DraftPresenceIndicator';
import SensitiveActionChecklist from '@/components/v1/SensitiveActionChecklist';
import { StickyActionsFooter } from '@/components/v1/StickyActionsFooter';
import { CollapsibleStatsGroup } from '@/components/v1/CollapsibleStatsGroup';
import { RewardBalanceSparklineCard } from '@/components/v1/RewardBalanceSparklineCard';
import GlobalStateStore from '@/services/global-state-store';
import { useWalletStatus } from '@/hooks/v1/useWalletStatus';
import type { RecentAccount } from '@/components/v1/AccountSwitcher.types';
Expand Down Expand Up @@ -152,6 +154,48 @@ const ProfileSettings: React.FC = () => {
{ 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) {
Expand Down Expand Up @@ -267,6 +311,47 @@ const ProfileSettings: React.FC = () => {

<div className="wallet-metadata" data-testid="profile-settings-wallet-meta">
<h3>Wallet</h3>
<div
data-testid="profile-settings-reward-trend-grid"
style={{
display: 'grid',
gap: '0.75rem',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
marginBottom: '1rem',
}}
>
<RewardBalanceSparklineCard
label="Earned rewards"
status={rewardTrendStatus}
error={error ?? undefined}
onRetry={() => 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"
/>
<RewardBalanceSparklineCard
label="Bonus momentum"
status={rewardTrendStatus}
error={error ?? undefined}
onRetry={() => 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"
/>
</div>
<CollapsibleStatsGroup
title="Profile overview stats"
summary={`${profileSummaryStats.length} metrics`}
items={profileSummaryStats}
defaultExpanded={false}
testId="profile-settings-overview-stats"
/>
<div style={{ marginBottom: '1rem' }}>
<AccountSwitcher
currentAddress={walletStatus.address}
Expand Down
17 changes: 17 additions & 0 deletions frontend/tests/components/GameLobby.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,4 +316,21 @@ describe("GameLobby", () => {
"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(<GameLobby />);

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");
});
});
66 changes: 53 additions & 13 deletions frontend/tests/components/ProfileSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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' });
});
Expand Down Expand Up @@ -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(<ProfileSettings />);

await waitFor(() => {
expect(screen.getByTestId('profile-settings-reward-earned')).toBeInTheDocument();
expect(screen.getByTestId('profile-settings-reward-bonus-empty')).toBeInTheDocument();
});
});
Comment on lines +100 to 123
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Test assertion expects a testId that is not rendered.

Line 121 expects profile-settings-reward-bonus-empty, but ProfileSettings.tsx line 345 always renders testId="profile-settings-reward-bonus" regardless of wallet connection state. Unless RewardBalanceSparklineCard internally modifies the testId when rendering an empty state, this assertion will fail.

🐛 Proposed fix

If RewardBalanceSparklineCard does not append -empty to the testId, update the test assertion:

     await waitFor(() => {
       expect(screen.getByTestId('profile-settings-reward-earned')).toBeInTheDocument();
-      expect(screen.getByTestId('profile-settings-reward-bonus-empty')).toBeInTheDocument();
+      expect(screen.getByTestId('profile-settings-reward-bonus')).toBeInTheDocument();
     });

Alternatively, if the component should distinguish empty states in its testId, update ProfileSettings.tsx to conditionally append -empty:

           <RewardBalanceSparklineCard
             label="Bonus momentum"
             status={rewardTrendStatus}
             error={error ?? undefined}
             onRetry={() => 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"
+            testId={`profile-settings-reward-bonus${walletMeta.connected ? '' : '-empty'}`}
           />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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(<ProfileSettings />);
await waitFor(() => {
expect(screen.getByTestId('profile-settings-reward-earned')).toBeInTheDocument();
expect(screen.getByTestId('profile-settings-reward-bonus-empty')).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(<ProfileSettings />);
await waitFor(() => {
expect(screen.getByTestId('profile-settings-reward-earned')).toBeInTheDocument();
expect(screen.getByTestId('profile-settings-reward-bonus')).toBeInTheDocument();
});
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/tests/components/ProfileSettings.test.tsx` around lines 100 - 123,
The test is asserting for testId "profile-settings-reward-bonus-empty" but
ProfileSettings.tsx always renders testId "profile-settings-reward-bonus" (see
ProfileSettings component and its use of RewardBalanceSparklineCard); either
update the test to expect "profile-settings-reward-bonus" or change
ProfileSettings.tsx to conditionally render the empty variant (e.g., append
"-empty" to the testId when walletStatusState.capabilities.isConnected is false
or when RewardBalanceSparklineCard would render an empty state) so the testId in
the rendered output matches the test assertion.


Expand Down
Loading