Compare commits

...

8 Commits

Author SHA1 Message Date
semantic-release-bot
15029ae52f chore(release): 2.9.0 [skip ci]
## [2.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.7...v2.9.0) (2025-10-09)

### Features

* implement auto-save for player settings modal ([a83dc09](a83dc097e4))
2025-10-09 12:48:28 +00:00
Thomas Hallock
a83dc097e4 feat: implement auto-save for player settings modal
Convert PlayerConfigDialog from explicit save button to auto-save UX:
- Add debounced name updates (500ms delay after typing stops)
- Add visual save status indicator ("Saving..." / "Changes saved automatically")
- Remove "Cancel" and "Save Changes" buttons
- Change modal title to "Player Settings"

Fix type errors from previous commits:
- Add notifyRoomOfPlayerUpdate to all useRoomData test mocks
- Fix type predicate for player filtering in PageWithNav
- Fix createdAt comparison to handle Date | number type
- Remove autoFocus attribute (accessibility linting rule)

All player settings (name and emoji) now save automatically without
requiring an explicit save button, providing a smoother user experience.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 07:47:36 -05:00
semantic-release-bot
0abec1a3bb chore(release): 2.8.7 [skip ci]
## [2.8.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.6...v2.8.7) (2025-10-09)

### Bug Fixes

* enable real-time player name updates across room members ([5171be3](5171be3d37))
2025-10-09 12:39:34 +00:00
Thomas Hallock
5171be3d37 fix: enable real-time player name updates across room members
When a player's name (or other properties) is updated, emit a
'players-updated' socket event to notify all room members. This triggers
the server to broadcast the updated player data via 'room-players-updated',
which all room members receive and use to update their local state.

Changes:
- Add notifyRoomOfPlayerUpdate() function to useRoomData hook
- Call it after successful player mutations (create, update, delete, setActive)
- This ensures all room members see player changes immediately without page reload

Fixes real-time synchronization of player names in the mini app nav.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 07:38:43 -05:00
semantic-release-bot
fc9eb253ad chore(release): 2.8.6 [skip ci]
## [2.8.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.5...v2.8.6) (2025-10-09)

### Bug Fixes

* prevent duplicate display of network avatars in nav ([d474ef0](d474ef07d6))
2025-10-09 12:27:57 +00:00
Thomas Hallock
d474ef07d6 fix: prevent duplicate display of network avatars in nav
Filter out remote players (isLocal: false) from active/inactive player
lists in PageWithNav to prevent them from appearing twice:
1. Once in the main player list (incorrect)
2. Once in the network players section (correct)

Now:
- Active/inactive player lists show only local players
- Network players section shows remote players separately

This provides a clear visual distinction between "your avatars" and
"network avatars" in the mini app game nav.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 07:27:03 -05:00
semantic-release-bot
3cdc0695f4 chore(release): 2.8.5 [skip ci]
## [2.8.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.4...v2.8.5) (2025-10-09)

### Bug Fixes

* remove redirect loop by not redirecting from room page ([10cf715](10cf71527f))
2025-10-09 01:17:27 +00:00
Thomas Hallock
10cf71527f fix: remove redirect loop by not redirecting from room page
The infinite redirect loop was caused by:
1. /arcade/room redirecting to /arcade when roomData is null
2. /arcade (via useArcadeRedirect) redirecting back to /arcade/room for active session
3. Loop repeats

Fix: Remove the redirect from room page entirely. Instead:
- Show loading state while fetching roomData
- Show error message with link if no room found (no automatic redirect)
- Let useArcadeRedirect on /arcade handle active session redirects

This prevents the redirect conflict and allows proper navigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 20:16:27 -05:00
8 changed files with 173 additions and 103 deletions

View File

@@ -1,3 +1,31 @@
## [2.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.7...v2.9.0) (2025-10-09)
### Features
* implement auto-save for player settings modal ([a83dc09](https://github.com/antialias/soroban-abacus-flashcards/commit/a83dc097e43c265a297281da54754f58ac831754))
## [2.8.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.6...v2.8.7) (2025-10-09)
### Bug Fixes
* enable real-time player name updates across room members ([5171be3](https://github.com/antialias/soroban-abacus-flashcards/commit/5171be3d37980eb1c98aa0d1e1d6e06f589763d1))
## [2.8.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.5...v2.8.6) (2025-10-09)
### Bug Fixes
* prevent duplicate display of network avatars in nav ([d474ef0](https://github.com/antialias/soroban-abacus-flashcards/commit/d474ef07d69cf0b4f5dedd404616e3bbee7289fe))
## [2.8.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.4...v2.8.5) (2025-10-09)
### Bug Fixes
* remove redirect loop by not redirecting from room page ([10cf715](https://github.com/antialias/soroban-abacus-flashcards/commit/10cf71527f7cede7fd93e502dbfc59df99b5a524))
## [2.8.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.3...v2.8.4) (2025-10-09)

View File

@@ -90,6 +90,7 @@ describe('Room Navigation with Active Sessions', () => {
},
isLoading: false,
isInRoom: true,
notifyRoomOfPlayerUpdate: vi.fn(),
})
// Mock rooms API
@@ -140,6 +141,7 @@ describe('Room Navigation with Active Sessions', () => {
roomData: null,
isLoading: false,
isInRoom: false,
notifyRoomOfPlayerUpdate: vi.fn(),
})
;(global.fetch as any).mockResolvedValue({
@@ -172,6 +174,7 @@ describe('Room Navigation with Active Sessions', () => {
roomData: null,
isLoading: false,
isInRoom: false,
notifyRoomOfPlayerUpdate: vi.fn(),
})
;(global.fetch as any).mockResolvedValue({
@@ -225,6 +228,7 @@ describe('Room Navigation with Active Sessions', () => {
roomData: null,
isLoading: false,
isInRoom: false,
notifyRoomOfPlayerUpdate: vi.fn(),
})
;(global.fetch as any).mockResolvedValue({
@@ -271,6 +275,7 @@ describe('Room Navigation with Active Sessions', () => {
},
isLoading: false,
isInRoom: true,
notifyRoomOfPlayerUpdate: vi.fn(),
})
;(global.fetch as any).mockResolvedValue({

View File

@@ -1,7 +1,5 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { useRoomData } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairsContext'
@@ -9,18 +7,14 @@ import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairs
/**
* /arcade/room - Renders the game for the user's current room
* Since users can only be in one room at a time, this is a simple singular route
*
* Note: We don't redirect to /arcade if no room exists because:
* - It would conflict with arcade session redirects and create loops
* - useArcadeRedirect on /arcade page handles redirecting to active sessions
*/
export default function RoomPage() {
const router = useRouter()
const { roomData, isLoading } = useRoomData()
// Redirect to arcade if no room
useEffect(() => {
if (!isLoading && !roomData) {
router.push('/arcade')
}
}, [isLoading, roomData, router])
// Show loading state
if (isLoading) {
return (
@@ -39,9 +33,33 @@ export default function RoomPage() {
)
}
// Show nothing while redirecting
// Show error if no room (instead of redirecting)
if (!roomData) {
return null
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
gap: '1rem',
}}
>
<div>No active room found</div>
<a
href="/arcade"
style={{
color: '#3b82f6',
textDecoration: 'underline',
}}
>
Go to Champion Arena
</a>
</div>
)
}
// Render the appropriate game based on room's gameName

View File

@@ -58,13 +58,14 @@ export function PageWithNav({
}
// Get active and inactive players as arrays
// Only show LOCAL players in the active/inactive lists (remote players shown separately in networkPlayers)
const activePlayerList = Array.from(activePlayers)
.map((id) => players.get(id))
.filter((p) => p !== undefined)
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false) // Filter out remote players
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
const inactivePlayerList = Array.from(players.values())
.filter((p) => !activePlayers.has(p.id))
.filter((p) => !activePlayers.has(p.id) && p.isLocal !== false) // Filter out remote players
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
// Compute game mode from active player count

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { EmojiPicker } from '../../app/games/matching/components/EmojiPicker'
import { useGameMode } from '../../contexts/GameModeContext'
@@ -11,17 +11,36 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
// All hooks must be called before early return
const { getPlayer, updatePlayer, players } = useGameMode()
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [localName, setLocalName] = useState('')
const [isSaving, setIsSaving] = useState(false)
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
const player = getPlayer(playerId)
const [tempName, setTempName] = useState(player?.name || '')
// Initialize local name from player
useEffect(() => {
if (player) {
setLocalName(player.name)
}
}, [player])
if (!player) {
return null
}
const handleSave = () => {
updatePlayer(playerId, { name: tempName })
onClose()
const handleNameChange = (newName: string) => {
setLocalName(newName)
// Debounce the update to avoid too many API calls
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
setIsSaving(true)
debounceTimerRef.current = setTimeout(() => {
updatePlayer(playerId, { name: newName })
setIsSaving(false)
}, 500) // Wait 500ms after user stops typing
}
const handleEmojiSelect = (emoji: string) => {
@@ -30,7 +49,13 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
}
// Get player number for UI theming (first 4 players get special colors)
const allPlayers = Array.from(players.values()).sort((a, b) => a.createdAt - b.createdAt)
const allPlayers = Array.from(players.values()).sort((a, b) => {
const aTime = typeof a.createdAt === 'number' ? a.createdAt :
a.createdAt instanceof Date ? a.createdAt.getTime() : 0
const bTime = typeof b.createdAt === 'number' ? b.createdAt :
b.createdAt instanceof Date ? b.createdAt.getTime() : 0
return aTime - bTime
})
const playerIndex = allPlayers.findIndex((p) => p.id === playerId)
const displayNumber = playerIndex + 1
@@ -81,22 +106,35 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
alignItems: 'flex-start',
marginBottom: '24px',
}}
>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
backgroundClip: 'text',
color: 'transparent',
margin: 0,
}}
>
Configure Player
</h2>
<div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
backgroundClip: 'text',
color: 'transparent',
margin: 0,
marginBottom: '4px',
}}
>
Player Settings
</h2>
<div
style={{
fontSize: '12px',
color: isSaving ? '#f59e0b' : '#10b981',
fontWeight: '500',
opacity: 0.8,
}}
>
{isSaving ? '💾 Saving...' : '✓ Changes saved automatically'}
</div>
</div>
<button
onClick={onClose}
style={{
@@ -198,7 +236,7 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
</div>
{/* Name Input */}
<div style={{ marginBottom: '24px' }}>
<div>
<label
style={{
display: 'block',
@@ -212,8 +250,8 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
</label>
<input
type="text"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
value={localName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Player Name"
maxLength={20}
style={{
@@ -243,69 +281,9 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
textAlign: 'right',
}}
>
{tempName.length}/20 characters
{localName.length}/20 characters
</div>
</div>
{/* Action Buttons */}
<div
style={{
display: 'flex',
gap: '12px',
}}
>
<button
onClick={onClose}
style={{
flex: 1,
padding: '12px',
background: 'white',
border: '2px solid #e5e7eb',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '600',
color: '#6b7280',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f9fafb'
e.currentTarget.style.borderColor = '#d1d5db'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'white'
e.currentTarget.style.borderColor = '#e5e7eb'
}}
>
Cancel
</button>
<button
onClick={handleSave}
style={{
flex: 1,
padding: '12px',
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
border: 'none',
borderRadius: '12px',
fontSize: '14px',
fontWeight: '600',
color: 'white',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0,0,0,0.2)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'
}}
>
Save Changes
</button>
</div>
</div>
</div>
)

View File

@@ -68,7 +68,7 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
const { mutate: createPlayer } = useCreatePlayer()
const { mutate: updatePlayerMutation } = useUpdatePlayer()
const { mutate: deletePlayer } = useDeletePlayer()
const { roomData } = useRoomData()
const { roomData, notifyRoomOfPlayerUpdate } = useRoomData()
const { data: viewerId } = useViewerId()
const [isInitialized, setIsInitialized] = useState(false)
@@ -167,14 +167,27 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
isActive: playerData?.isActive ?? false,
}
createPlayer(newPlayer)
createPlayer(newPlayer, {
onSuccess: () => {
// Notify room members if in a room
notifyRoomOfPlayerUpdate()
},
})
}
const updatePlayer = (id: string, updates: Partial<Player>) => {
const player = players.get(id)
// Only allow updating local players
if (player?.isLocal) {
updatePlayerMutation({ id, updates })
updatePlayerMutation(
{ id, updates },
{
onSuccess: () => {
// Notify room members if in a room
notifyRoomOfPlayerUpdate()
},
}
)
} else {
console.warn('[GameModeContext] Cannot update remote player:', id)
}
@@ -184,7 +197,12 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
const player = players.get(id)
// Only allow removing local players
if (player?.isLocal) {
deletePlayer(id)
deletePlayer(id, {
onSuccess: () => {
// Notify room members if in a room
notifyRoomOfPlayerUpdate()
},
})
} else {
console.warn('[GameModeContext] Cannot remove remote player:', id)
}
@@ -194,7 +212,15 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
const player = players.get(id)
// Only allow changing active status of local players
if (player?.isLocal) {
updatePlayerMutation({ id, updates: { isActive: active } })
updatePlayerMutation(
{ id, updates: { isActive: active } },
{
onSuccess: () => {
// Notify room members if in a room
notifyRoomOfPlayerUpdate()
},
}
)
} else {
console.warn('[GameModeContext] Cannot change active status of remote player:', id)
}
@@ -227,6 +253,11 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
isActive: index === 0,
})
})
// Notify room members after reset (slight delay to ensure mutations complete)
setTimeout(() => {
notifyRoomOfPlayerUpdate()
}, 100)
}
const activePlayerCount = activePlayers.size

View File

@@ -196,10 +196,19 @@ export function useRoomData() {
}
}, [socket, roomData?.id])
// Function to notify room members of player updates
const notifyRoomOfPlayerUpdate = () => {
if (socket && roomData?.id && userId) {
console.log('[useRoomData] Notifying room of player update')
socket.emit('players-updated', { roomId: roomData.id, userId })
}
}
return {
roomData,
// Loading if: userId is pending, currently fetching, or have userId but haven't tried fetching yet
isLoading: isUserIdPending || isLoading || (!!userId && !hasAttemptedFetch),
isInRoom: !!roomData,
notifyRoomOfPlayerUpdate,
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "2.8.4",
"version": "2.9.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [