Compare commits

...

4 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
7 changed files with 127 additions and 90 deletions

View File

@@ -1,3 +1,17 @@
## [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)

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

@@ -61,7 +61,7 @@ export function PageWithNav({
// 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 && p.isLocal !== false) // Filter out remote players
.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())

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.6",
"version": "2.9.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [