Skip to content

Commit da9cde7

Browse files
feat: persist state
Signed-off-by: Jonathan Irvin <[email protected]>
1 parent a25f835 commit da9cde7

File tree

10 files changed

+1042
-51
lines changed

10 files changed

+1042
-51
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,4 @@ Thumbs.db
5252

5353
# Poetry lock file (optional – some teams prefer to version-control this)
5454
poetry.lock
55+
.nicegui/

CONTEXT.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# BINGO App Context
2+
3+
## State Persistence
4+
- Game state is persisted across app restarts using `app.storage.general`
5+
- Clicked tiles, game status (open/closed), and header text are saved
6+
- Implemented serialization/deserialization for Python types (sets, tuples)
7+
- Key functions (toggle_tile, reset_board, close_game) save state after changes
8+
- App initialization loads state from storage if available
9+
10+
## View Synchronization
11+
- Two main views: root (/) and stream (/stream)
12+
- NiceGUI 2.11+ compatibility implemented (removed ui.broadcast())
13+
- Timer-based synchronization at 0.05 second intervals
14+
- sync_board_state() handles synchronization between views
15+
- Both views reflect same game state, header text, and board visibility
16+
17+
## User Connection Tracking
18+
- Connected users tracked on both root and stream paths
19+
- Connection/disconnection handled properly
20+
- Health endpoint reports active user counts
21+
- UI displays active user count
22+
- Maintains lists of active user sessions
23+
24+
## Mobile UI Improvements
25+
- Control buttons include text alongside icons
26+
- Improved button styling for better touch targets
27+
- Clearer tooltips with descriptive text
28+
- Consistent styling between game states
29+
30+
## Key Functions
31+
- save_state_to_storage() - Serializes game state to app.storage.general
32+
- load_state_from_storage() - Loads and deserializes state from storage
33+
- toggle_tile() - Updates state and uses timer-based sync
34+
- close_game() - Saves state and updates synchronized views
35+
- reopen_game() - Restores state across synchronized views
36+
- home_page() - Includes user tracking functionality
37+
- stream_page() - Includes user tracking functionality
38+
- health() - Reports system status including active users
39+
40+
## Testing
41+
- State persistence tests verify storage functionality
42+
- Synchronization tests ensure consistent views
43+
- NiceGUI 2.11+ compatibility tests
44+
- UI component tests for controls and board
45+
- User tracking tests for connection handling

NICEGUI_NOTES.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# NiceGUI Documentation Notes
2+
3+
## Version: 2.11.0
4+
5+
## Key Features and APIs
6+
7+
### app.storage
8+
- Built-in persistent storage system
9+
- Available as `app.storage.general` for general-purpose storage
10+
- Automatically persists across app restarts
11+
- Usage:
12+
```python
13+
# Store data
14+
app.storage.general['key'] = value
15+
16+
# Retrieve data
17+
value = app.storage.general.get('key', default_value)
18+
19+
# Check if key exists
20+
if 'key' in app.storage.general:
21+
# ...
22+
```
23+
- Storage is JSON-serializable, must handle conversion of non-JSON types (like sets)
24+
25+
### Client Synchronization
26+
- `ui.timer(interval, callback)`: Runs a function periodically (interval in seconds)
27+
- `app.on_disconnect(function)`: Registers a callback for when client disconnects
28+
- In NiceGUI 2.11+, updates to UI elements are automatically pushed to all clients
29+
- Use timers for periodic synchronization between clients
30+
- For immediate updates across clients, use a smaller timer interval (e.g., 0.05 seconds)
31+
32+
### UI Controls
33+
- Buttons support both text and icons:
34+
```python
35+
ui.button("Text", icon="icon_name", on_click=handler)
36+
```
37+
- Mobile-friendly practices:
38+
- Use larger touch targets (at least 44x44 pixels)
39+
- Add descriptive text to icons
40+
- Use responsive classes
41+
42+
## Best Practices
43+
44+
### State Management
45+
1. Store state in app.storage.general for persistence
46+
2. Convert Python-specific types (sets, tuples) to JSON-compatible types:
47+
- Sets → Lists
48+
- Tuples → Lists
49+
3. Convert back when loading
50+
4. Handle serialization errors gracefully
51+
52+
### Synchronization Between Views
53+
1. NiceGUI 2.11+ automatically synchronizes UI element updates between clients
54+
2. Use timers for consistent state synchronization
55+
3. For best results, combine:
56+
- Fast timers (0.05 seconds) for responsive updates
57+
- Shared state in app.storage for persistence
58+
- State loading on page initialization
59+
60+
### Error Handling
61+
1. Wrap storage operations in try/except
62+
2. Handle disconnected clients gracefully
63+
3. Log issues with appropriate level (debug vs error)
64+
65+
### Testing
66+
1. Mock app.storage for unit tests
67+
2. Test serialization/deserialization edge cases
68+
3. Simulate app restarts for integration tests
69+
70+
## Documentation References
71+
- Full API Documentation: https://nicegui.io/documentation
72+
- State Management: https://nicegui.io/documentation#app_storage
73+
- Timers and Async: https://nicegui.io/documentation#ui_timer
74+
- Broadcasting: https://nicegui.io/documentation#ui_broadcast

app.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
generate_board,
1818
is_game_closed,
1919
today_seed,
20+
load_state_from_storage,
2021
)
2122
from src.ui.routes import init_routes
2223
from src.utils.file_operations import read_phrases_file
@@ -30,10 +31,18 @@
3031
# Initialize the application
3132
def init_app():
3233
"""Initialize the Bingo application."""
33-
34-
# Initialize game state
35-
phrases = read_phrases_file()
36-
generate_board(board_iteration, phrases)
34+
# Ensure storage is initialized
35+
if not hasattr(app.storage, 'general'):
36+
app.storage.general = {}
37+
38+
# Try to load state from storage first
39+
if load_state_from_storage():
40+
logging.info("Game state loaded from persistent storage")
41+
else:
42+
# If no saved state exists, initialize fresh game state
43+
logging.info("No saved state found, initializing fresh game state")
44+
phrases = read_phrases_file()
45+
generate_board(board_iteration, phrases)
3746

3847
# Initialize routes
3948
init_routes()
@@ -48,4 +57,4 @@ def init_app():
4857
if __name__ in {"__main__", "__mp_main__"}:
4958
# Run the NiceGUI app
5059
init_app()
51-
ui.run(port=8080, title=f"{HEADER_TEXT}", dark=False)
60+
ui.run(port=8080, title=f"{HEADER_TEXT}", dark=False, storage_secret=os.getenv("STORAGE_SECRET","ThisIsMyCrappyStorageSecret"))

src/core/game_logic.py

Lines changed: 104 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
import datetime
66
import logging
77
import random
8-
from typing import List, Optional, Set, cast
8+
import json
9+
from typing import List, Optional, Set, Dict, Any, Tuple, cast
910

10-
from nicegui import ui
11+
from nicegui import ui, app
1112

1213
from src.config.constants import (
1314
CLOSED_HEADER_TEXT,
@@ -103,6 +104,9 @@ def toggle_tile(row: int, col: int) -> None:
103104
clicked_tiles.add(key)
104105

105106
check_winner()
107+
108+
# Save state to storage after each tile toggle for persistence
109+
save_state_to_storage()
106110

107111
for view_key, (container, tile_buttons_local) in board_views.items():
108112
for (r, c), tile in tile_buttons_local.items():
@@ -143,6 +147,10 @@ def toggle_tile(row: int, col: int) -> None:
143147
}, 50);
144148
"""
145149
ui.run_javascript(js_code)
150+
151+
# In NiceGUI 2.11+, updates are automatically synchronized between clients
152+
# via the timer-based sync_board_state function running frequently
153+
logging.debug("UI updates will be synchronized by timers")
146154
except Exception as e:
147155
logging.debug(f"JavaScript execution failed: {e}")
148156

@@ -265,6 +273,9 @@ def reset_board() -> None:
265273
for c, phrase in enumerate(row):
266274
if phrase.upper() == FREE_SPACE_TEXT:
267275
clicked_tiles.add((r, c))
276+
277+
# Save state after reset for persistence
278+
save_state_to_storage()
268279

269280

270281
def generate_new_board(phrases: List[str]) -> None:
@@ -322,19 +333,17 @@ def close_game() -> None:
322333
if controls_row is not None:
323334
controls_row.clear()
324335
with controls_row:
325-
with ui.button("", icon="autorenew", on_click=reopen_game).classes(
326-
"rounded-full w-12 h-12"
336+
with ui.button("Start New Game", icon="autorenew", on_click=reopen_game).classes(
337+
"px-4 py-2"
327338
) as new_game_btn:
328-
ui.tooltip("Start New Game")
339+
ui.tooltip("Start a new game with a fresh board")
329340

330-
# Update stream page as well - this will trigger sync_board_state on connected clients
331-
# Note: ui.broadcast() was used in older versions of NiceGUI, but may not be available
332-
try:
333-
ui.broadcast() # Broadcast changes to all connected clients
334-
except AttributeError:
335-
# In newer versions of NiceGUI, broadcast might not be available
336-
# We rely on the timer-based sync instead
337-
logging.info("ui.broadcast not available, relying on timer-based sync")
341+
# Save game state with is_game_closed=True for persistence
342+
save_state_to_storage()
343+
344+
# In NiceGUI 2.11+, updates are automatically synchronized between clients
345+
# via the timer-based sync_board_state function
346+
logging.info("Game closed - changes will be synchronized by timers")
338347

339348
# Notify that game has been closed
340349
ui.notify("Game has been closed", color="red", duration=3)
@@ -385,11 +394,86 @@ def reopen_game() -> None:
385394
# Notify that a new game has started
386395
ui.notify("New game started", color="green", duration=3)
387396

388-
# Update stream page and all other connected clients
389-
# This will trigger sync_board_state on all clients including the stream view
397+
# In NiceGUI 2.11+, updates are automatically synchronized between clients
398+
# via the timer-based sync_board_state function
399+
logging.info("Game reopened - changes will be synchronized by timers")
400+
401+
# Save state to storage for persistence across app restarts
402+
save_state_to_storage()
403+
404+
405+
def save_state_to_storage() -> bool:
406+
"""
407+
Save the current game state to app.storage.general for persistence
408+
across application restarts.
409+
410+
Returns:
411+
bool: True if state was saved successfully, False otherwise
412+
"""
413+
try:
414+
if not hasattr(app, 'storage') or not hasattr(app.storage, 'general'):
415+
logging.warning("app.storage.general not available")
416+
return False
417+
418+
# Convert non-JSON-serializable types to serializable equivalents
419+
clicked_tiles_list = list(tuple(coord) for coord in clicked_tiles)
420+
bingo_patterns_list = list(bingo_patterns)
421+
422+
# Prepare state dictionary
423+
state = {
424+
'board': board,
425+
'clicked_tiles': clicked_tiles_list,
426+
'bingo_patterns': bingo_patterns_list,
427+
'board_iteration': board_iteration,
428+
'is_game_closed': is_game_closed,
429+
'today_seed': today_seed
430+
}
431+
432+
# Save to storage
433+
app.storage.general['game_state'] = state
434+
logging.debug("Game state saved to persistent storage")
435+
return True
436+
except Exception as e:
437+
logging.error(f"Error saving state to storage: {e}")
438+
return False
439+
440+
441+
def load_state_from_storage() -> bool:
442+
"""
443+
Load game state from app.storage.general if available.
444+
445+
Returns:
446+
bool: True if state was loaded successfully, False otherwise
447+
"""
448+
global board, clicked_tiles, bingo_patterns, board_iteration, is_game_closed, today_seed
449+
390450
try:
391-
ui.broadcast() # Broadcast changes to all connected clients
392-
except AttributeError:
393-
# In newer versions of NiceGUI, broadcast might not be available
394-
# We rely on the timer-based sync instead
395-
logging.info("ui.broadcast not available, relying on timer-based sync")
451+
if not hasattr(app, 'storage') or not hasattr(app.storage, 'general'):
452+
logging.warning("app.storage.general not available")
453+
return False
454+
455+
if 'game_state' not in app.storage.general:
456+
logging.debug("No saved game state found in storage")
457+
return False
458+
459+
state = app.storage.general['game_state']
460+
461+
# Load board
462+
board = state['board']
463+
464+
# Convert clicked_tiles from list back to set
465+
clicked_tiles = set(tuple(coord) for coord in state['clicked_tiles'])
466+
467+
# Convert bingo_patterns from list back to set
468+
bingo_patterns = set(state['bingo_patterns'])
469+
470+
# Load other state variables
471+
board_iteration = state['board_iteration']
472+
is_game_closed = state['is_game_closed']
473+
today_seed = state['today_seed']
474+
475+
logging.debug("Game state loaded from persistent storage")
476+
return True
477+
except Exception as e:
478+
logging.error(f"Error loading state from storage: {e}")
479+
return False

src/ui/controls.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,18 @@ def create_controls_row():
2727
phrases = read_phrases_file()
2828

2929
with ui.row().classes("w-full mt-4 items-center justify-center gap-4") as row:
30-
with ui.button("", icon="refresh", on_click=lambda: reset_board()).classes(
31-
"rounded-full w-12 h-12"
30+
with ui.button("Reset", icon="refresh", on_click=lambda: reset_board()).classes(
31+
"px-4 py-2"
3232
) as reset_btn:
33-
ui.tooltip("Reset Board")
33+
ui.tooltip("Reset the board while keeping the same phrases")
3434
with ui.button(
35-
"", icon="autorenew", on_click=lambda: generate_new_board(phrases)
36-
).classes("rounded-full w-12 h-12") as new_board_btn:
37-
ui.tooltip("New Board")
38-
with ui.button("", icon="close", on_click=close_game).classes(
39-
"rounded-full w-12 h-12 bg-red-500"
35+
"New Board", icon="autorenew", on_click=lambda: generate_new_board(phrases)
36+
).classes("px-4 py-2") as new_board_btn:
37+
ui.tooltip("Generate a completely new board")
38+
with ui.button("Close Game", icon="close", on_click=close_game).classes(
39+
"px-4 py-2 bg-red-500"
4040
) as close_btn:
41-
ui.tooltip("Close Game")
41+
ui.tooltip("Close the game for all viewers")
4242
ui_seed_label = (
4343
ui.label(f"Seed: {today_seed}")
4444
.classes("text-sm text-center")
@@ -64,18 +64,18 @@ def rebuild_controls_row(row):
6464

6565
row.clear()
6666
with row:
67-
with ui.button("", icon="refresh", on_click=lambda: reset_board()).classes(
68-
"rounded-full w-12 h-12"
67+
with ui.button("Reset", icon="refresh", on_click=lambda: reset_board()).classes(
68+
"px-4 py-2"
6969
) as reset_btn:
70-
ui.tooltip("Reset Board")
70+
ui.tooltip("Reset the board while keeping the same phrases")
7171
with ui.button(
72-
"", icon="autorenew", on_click=lambda: generate_new_board(phrases)
73-
).classes("rounded-full w-12 h-12") as new_board_btn:
74-
ui.tooltip("New Board")
75-
with ui.button("", icon="close", on_click=close_game).classes(
76-
"rounded-full w-12 h-12 bg-red-500"
72+
"New Board", icon="autorenew", on_click=lambda: generate_new_board(phrases)
73+
).classes("px-4 py-2") as new_board_btn:
74+
ui.tooltip("Generate a completely new board")
75+
with ui.button("Close Game", icon="close", on_click=close_game).classes(
76+
"px-4 py-2 bg-red-500"
7777
) as close_btn:
78-
ui.tooltip("Close Game")
78+
ui.tooltip("Close the game for all viewers")
7979
ui_seed_label = (
8080
ui.label(f"Seed: {today_seed}")
8181
.classes("text-sm text-center")

0 commit comments

Comments
 (0)