Surn commited on
Commit
fedb9b2
Β·
1 Parent(s): c7e5a36

Leaderboard Page Identity Update

Browse files
Files changed (2) hide show
  1. specs/leaderboard_spec.md +182 -140
  2. wrdler/leaderboard_page.py +89 -33
specs/leaderboard_spec.md CHANGED
@@ -1,6 +1,6 @@
1
- # Wrdler Leaderboard System Specification
2
 
3
- **Document Version:** 1.3.0
4
  **Target Project Version:** 0.2.0
5
  **Author:** GitHub Copilot
6
  **Date:** 2025-01-27
@@ -22,6 +22,7 @@
22
  10. [UI Components](#10-ui-components)
23
  11. [Testing Requirements](#11-testing-requirements)
24
  12. [Migration Notes](#12-migration-notes)
 
25
 
26
  ---
27
 
@@ -92,29 +93,29 @@ Each date/week has settings-based subfolders. The folder name (file_id) encodes
92
 
93
  ```
94
  HF_REPO_ID/
95
- ??? games/ # All game-related storage
96
- ? ??? {challenge_id}/ # Existing challenge storage
97
- ? ? ??? settings.json # entry_type: "challenge"
98
- ? ??? leaderboards/
99
- ? ??? daily/
100
- ? ? ??? 2025-01-27/
101
- ? ? ? ??? classic-classic-0/
102
- ? ? ? ? ??? settings.json
103
- ? ? ? ??? easy-easy-0/
104
- ? ? ? ??? settings.json
105
- ? ? ??? 2025-01-26/
106
- ? ? ??? classic-classic-0/
107
- ? ? ??? settings.json
108
- ? ??? weekly/
109
- ? ??? 2025-W04/
110
- ? ? ??? classic-classic-0/
111
- ? ? ? ??? settings.json
112
- ? ? ??? easy-too_easy-0/
113
- ? ? ??? settings.json
114
- ? ??? 2025-W03/
115
- ? ??? classic-classic-0/
116
- ? ??? settings.json
117
- ??? shortener.json # Existing URL shortener
118
  ```
119
 
120
  ### 3.2 File ID Format
@@ -153,52 +154,52 @@ Instead of maintaining an `index.json` file, leaderboards are discovered by:
153
  ### 3.4 Data Flow
154
 
155
  ```
156
- ??????????????????????
157
- ? Game Completion ?
158
- ??????????????????????
159
- ?
160
- ?
161
- ??????????????????????
162
- ? Get current game ?
163
- ? settings ?
164
- ??????????????????????
165
- ?
166
- ?
167
- ??????????????????????
168
- ? Build file_id ?
169
- ? prefix from ?
170
- ? settings ?
171
- ??????????????????????
172
- ?
173
- ?
174
- ??????????????????????
175
- ? Scan folders for ?
176
- ? matching file_id ?
177
- ? or create new ?
178
- ??????????????????????
179
- ?
180
- ?????????????
181
- ? ?
182
- ? ?
183
- ????????? ?????????????
184
- ? Daily ? ? Weekly ?
185
- ? LB ? ? LB ?
186
- ????????? ?????????????
187
- ? ?
188
- ????????????
189
- ?
190
- ?
191
- ???????????????????????
192
- ? Check if score ?
193
- ? qualifies (top ?
194
- ? 20 displayed) ?
195
- ???????????????????????
196
- ?
197
- ?
198
- ???????????????????????
199
- ? Update & Upload ?
200
- ? to HF repo ?
201
- ???????????????????????
202
  ```
203
 
204
  ---
@@ -524,7 +525,7 @@ Add to `_render_sidebar()` in `ui.py`:
524
 
525
  ```python
526
  st.header("Navigation")
527
- if st.button("?? Leaderboards", use_container_width=True):
528
  st.session_state["show_leaderboard_page"] = True
529
  st.rerun()
530
  ```
@@ -539,9 +540,9 @@ Modify `_game_over_content()` in `ui.py` to:
539
  ```python
540
  # After score submission
541
  if results["daily"]["qualified"]:
542
- st.success(f"?? You ranked #{results['daily']['rank']} on today's leaderboard!")
543
  if results["weekly"]["qualified"]:
544
- st.success(f"?? You ranked #{results['weekly']['rank']} on this week's leaderboard!")
545
  ```
546
 
547
  ### 10.3 Leaderboard Page Routing
@@ -553,7 +554,7 @@ In `run_app()`:
553
  if st.session_state.get("show_leaderboard_page", False):
554
  from wrdler.leaderboard_page import render_leaderboard_page
555
  render_leaderboard_page()
556
- if st.button("? Back to Game"):
557
  st.session_state["show_leaderboard_page"] = False
558
  st.rerun()
559
  return # Don't render game UI
@@ -606,7 +607,7 @@ class TestDateIds:
606
 
607
  ### Integration Tests
608
 
609
- - Test full flow: game completion ? leaderboard submission ? retrieval
610
  - Test with mock HuggingFace repository
611
  - Test folder-based discovery logic
612
  - Test concurrent submissions (edge case)
@@ -647,67 +648,108 @@ class TestDateIds:
647
 
648
  ---
649
 
650
- ## Appendix A: Example Daily Leaderboard JSON (Folder-Based)
651
-
652
- **Path:** `games/leaderboards/daily/2025-01-27/classic-classic-0/settings.json`
653
-
654
- ```json
655
- {
656
- "challenge_id": "2025-01-27/classic-classic-0",
657
- "entry_type": "daily",
658
- "game_mode": "classic",
659
- "grid_size": 8,
660
- "puzzle_options": {
661
- "spacer": 0,
662
- "may_overlap": false
663
- },
664
- "users": [
665
- {
666
- "uid": "20251130T190249Z-0XLG5O",
667
- "username": "Charles",
668
- "word_list": ["CHEER", "PLENTY", "REAL", "ARRIVE", "DEAF", "DAILY"],
669
- "word_list_difficulty": 117.48,
670
- "score": 39,
671
- "time": 132,
672
- "timestamp": "2025-11-30T19:02:49.544933+00:00",
673
- "source_challenge_id": null
674
- }
675
- ],
676
- "created_at": "2025-11-30T19:02:49.544933+00:00",
677
- "version": "0.2.0",
678
- "show_incorrect_guesses": true,
679
- "enable_free_letters": true,
680
- "wordlist_source": "classic.txt",
681
- "game_title": "Wrdler Gradio AI",
682
- "max_display_entries": 20
683
- }
684
- ```
685
-
686
- ---
687
-
688
- ## Appendix B: File ID Examples
689
-
690
- | Wordlist Source | Game Mode | Sequence | File ID |
691
- |-----------------|-----------|----------|---------|
692
- | `classic.txt` | `classic` | 0 | `classic-classic-0` |
693
- | `easy.txt` | `easy` | 0 | `easy-easy-0` |
694
- | `classic.txt` | `too easy` | 1 | `classic-too_easy-1` |
695
- | `fourth_grade.txt` | `classic` | 0 | `fourth_grade-classic-0` |
696
-
697
- ---
698
-
699
- ## Appendix C: Entry Type Comparison (Updated)
700
-
701
- | Field | daily | weekly | challenge |
702
- |-------|-------|--------|-----------|
703
- | `challenge_id` format | `"2025-01-27/classic-classic-0"` | `"2025-W04/easy-easy-0"` | `"20251130T190249Z-ABC123"` |
704
- | `entry_type` | `"daily"` | `"weekly"` | `"challenge"` |
705
- | Storage path | `games/leaderboards/daily/{date}/{file_id}/settings.json` | `games/leaderboards/weekly/{week}/{file_id}/settings.json` | `games/{id}/settings.json` |
706
- | Reset frequency | Daily (UTC midnight) | Weekly (Monday UTC midnight) | Never (permanent) |
707
- | Settings-based | Yes (file_id encodes settings) | Yes (file_id encodes settings) | N/A (settings fixed per challenge) |
708
- | `max_display_entries` | 20 | 20 | N/A (all users shown) |
709
- | Discovery method | Folder scan + prefix match | Folder scan + prefix match | Direct by ID |
710
-
711
- ---
712
-
713
- *End of Specification*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ο»Ώ# Wrdler Leaderboard System Specification
2
 
3
+ **Document Version:** 1.3.1
4
  **Target Project Version:** 0.2.0
5
  **Author:** GitHub Copilot
6
  **Date:** 2025-01-27
 
22
  10. [UI Components](#10-ui-components)
23
  11. [Testing Requirements](#11-testing-requirements)
24
  12. [Migration Notes](#12-migration-notes)
25
+ 13. [Operational Considerations](#13-operational-considerations)
26
 
27
  ---
28
 
 
93
 
94
  ```
95
  HF_REPO_ID/
96
+ β”œβ”€β”€ games/ # All game-related storage
97
+ β”‚ β”œβ”€β”€ {challenge_id}/ # Existing challenge storage
98
+ β”‚ β”‚ └── settings.json # entry_type: "challenge"
99
+ β”‚ └── leaderboards/
100
+ β”‚ β”œβ”€β”€ daily/
101
+ β”‚ β”‚ β”œβ”€β”€ 2025-01-27/
102
+ β”‚ β”‚ β”‚ β”œβ”€β”€ classic-classic-0/
103
+ β”‚ β”‚ β”‚ β”‚ └── settings.json
104
+ β”‚ β”‚ β”‚ └── easy-easy-0/
105
+ β”‚ β”‚ β”‚ └── settings.json
106
+ β”‚ β”‚ └── 2025-01-26/
107
+ β”‚ β”‚ └── classic-classic-0/
108
+ β”‚ β”‚ └── settings.json
109
+ β”‚ └── weekly/
110
+ β”‚ β”œβ”€β”€ 2025-W04/
111
+ β”‚ β”‚ β”œβ”€β”€ classic-classic-0/
112
+ β”‚ β”‚ β”‚ └── settings.json
113
+ β”‚ β”‚ └── easy-too_easy-0/
114
+ β”‚ β”‚ └── settings.json
115
+ β”‚ └── 2025-W03/
116
+ β”‚ └── classic-classic-0/
117
+ β”‚ └── settings.json
118
+ └── shortener.json # Existing URL shortener
119
  ```
120
 
121
  ### 3.2 File ID Format
 
154
  ### 3.4 Data Flow
155
 
156
  ```
157
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
158
+ β”‚ Game Completion β”‚
159
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
160
+ β”‚
161
+ β–Ό
162
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
163
+ β”‚ Get current game β”‚
164
+ β”‚ settings β”‚
165
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
166
+ β”‚
167
+ β–Ό
168
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
169
+ β”‚ Build file_id β”‚
170
+ β”‚ prefix from β”‚
171
+ β”‚ settings β”‚
172
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
173
+ β”‚
174
+ β–Ό
175
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
176
+ β”‚ Scan folders for β”‚
177
+ β”‚ matching file_id β”‚
178
+ β”‚ or create new β”‚
179
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
180
+ β”‚
181
+ β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”
182
+ β”‚ β”‚
183
+ β–Ό β–Ό
184
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
185
+ β”‚ Daily β”‚ β”‚ Weekly β”‚
186
+ β”‚ LB β”‚ β”‚ LB β”‚
187
+ β””β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
188
+ β”‚ β”‚
189
+ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
190
+ β”‚
191
+ β–Ό
192
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
193
+ β”‚ Check if score β”‚
194
+ β”‚ qualifies (top β”‚
195
+ β”‚ 20 displayed) β”‚
196
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
197
+ β”‚
198
+ β–Ό
199
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
200
+ β”‚ Update & Upload β”‚
201
+ β”‚ to HF repo β”‚
202
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
203
  ```
204
 
205
  ---
 
525
 
526
  ```python
527
  st.header("Navigation")
528
+ if st.button("πŸ† Leaderboards", use_container_width=True):
529
  st.session_state["show_leaderboard_page"] = True
530
  st.rerun()
531
  ```
 
540
  ```python
541
  # After score submission
542
  if results["daily"]["qualified"]:
543
+ st.success(f"πŸ† You ranked #{results['daily']['rank']} on today's leaderboard!")
544
  if results["weekly"]["qualified"]:
545
+ st.success(f"πŸ† You ranked #{results['weekly']['rank']} on this week's leaderboard!")
546
  ```
547
 
548
  ### 10.3 Leaderboard Page Routing
 
554
  if st.session_state.get("show_leaderboard_page", False):
555
  from wrdler.leaderboard_page import render_leaderboard_page
556
  render_leaderboard_page()
557
+ if st.button("← Back to Game"):
558
  st.session_state["show_leaderboard_page"] = False
559
  st.rerun()
560
  return # Don't render game UI
 
607
 
608
  ### Integration Tests
609
 
610
+ - Test full flow: game completion β†’ leaderboard submission β†’ retrieval
611
  - Test with mock HuggingFace repository
612
  - Test folder-based discovery logic
613
  - Test concurrent submissions (edge case)
 
648
 
649
  ---
650
 
651
+ ## 13. Operational Considerations
652
+
653
+ ### 13.1 Concurrency and Consistency
654
+
655
+ - Write model:
656
+ - Use optimistic concurrency: read `settings.json`, merge new `users` entry, write back with a unique commit message including `challenge_id` and `uid`.
657
+ - Implement retry with exponential backoff on HTTP 409/5xx or checksum mismatch.
658
+ - Ensure atomicity per file write: do not split updates across multiple files per leaderboard.
659
+ - Simultaneous file creation:
660
+ - When no matching `file_id` folder exists, first attempt creation; if a concurrent process creates it, fallback to loading and merging.
661
+ - Always re-verify settings match by reading `settings.json` after folder discovery.
662
+ - Sequence collisions:
663
+ - If `{wordlist}-{mode}-{sequence}` collides but settings differ, increment `sequence` until a unique folder is found; verify match via file content, not only prefix.
664
+
665
+ ### 13.2 Caching and Discovery Performance
666
+
667
+ - Cache tiers:
668
+ - In-memory (per app session):
669
+ - Period listings for `games/leaderboards/{type}/` (TTL 60s).
670
+ - `file_id` listings inside a period (TTL 30s).
671
+ - Loaded `settings.json` for leaderboards (TTL 15s or invalidated on write).
672
+ - Invalidation:
673
+ - On successful submission, invalidate the specific leaderboard cache (file content and directory listing for that period).
674
+ - Provide a manual refresh option in UI (leaderboard page).
675
+ - Discovery limits:
676
+ - Cap directory scans to the most recent N periods (configurable; default 30). UI uses explicit period selection for older data.
677
+ - Prefer prefix filtering client-side before loading file content.
678
+
679
+ ### 13.3 Error Handling and Observability
680
+
681
+ - Error taxonomy:
682
+ - Storage errors: `HF_STORAGE_UNAVAILABLE`, `HF_WRITE_CONFLICT`, `HF_NOT_FOUND`.
683
+ - Validation errors: `LB_INVALID_INPUT`, `LB_SETTINGS_MISMATCH`.
684
+ - Operational errors: `LB_TIMEOUT`, `LB_RETRY_EXCEEDED`.
685
+ - User feedback:
686
+ - On non-critical failure (e.g., leaderboard write conflict), show non-blocking warning and retry silently up to 3 times.
687
+ - Logging:
688
+ - Log submission events with fields: `entry_type`, `period_id`, `file_id`, `uid`, `score`, `time`, `rank_result`, `repo_path`, `latency_ms`.
689
+ - Log error events with `code`, `message`, `attempt`, `backoff_ms`.
690
+ - Telemetry (optional):
691
+ - Count successful submissions per period and per settings combination for basic monitoring.
692
+
693
+ ### 13.4 Security and Abuse Controls
694
+
695
+ - Input validation:
696
+ - `username`: max 40 chars, strip control chars, allow alphanumerics, spaces, basic punctuation; reject offensive content if possible.
697
+ - `word_list`: array of 6 uppercase A–Z strings, length 3–10; drop invalid entries.
698
+ - `score`: 0–999; `time`: 1–36000 (10 hours); `word_list_difficulty`: float if provided, clamp to 0–10000.
699
+ - Spam and duplicates:
700
+ - Rate limit per IP/session (e.g., max 10 submissions per hour).
701
+ - Detect duplicate entries by same `uid` + `timestamp` within 10 seconds window; deduplicate silently.
702
+ - Repository permissions:
703
+ - Submissions require HF write permissions for the space; ensure credentials are scoped to the specific repo.
704
+ - Do not expose write tokens in client logs; keep server-side commit operations.
705
+
706
+ ### 13.5 Data Lifecycle and Retention
707
+
708
+ - Retention:
709
+ - Keep daily leaderboards for 365 days; weekly leaderboards for 156 weeks (3 years).
710
+ - Optional archival: move older periods to `games/leaderboards_archive/{type}/` or leave as-is with documented retention.
711
+ - Cleanup:
712
+ - Provide a maintenance script to prune old periods and reindex cache.
713
+ - Privacy:
714
+ - Store only display names and gameplay metrics; avoid PII.
715
+ - Users may choose β€œAnonymous”; do not display IP or identifiers publicly.
716
+
717
+ ### 13.6 Time and Period Boundaries
718
+
719
+ - Timezone:
720
+ - All operations use UTC. The periods roll over at 00:00:00 UTC for daily, Monday 00:00:00 UTC for weekly.
721
+ - ISO week:
722
+ - Use Python’s `isocalendar()` to derive `YYYY-Www` and handle year transitions (weeks spanning year boundaries).
723
+ - Clock source:
724
+ - Use server-side timestamp for submissions; do not trust client clock. If unavailable, fall back to Python `datetime.utcnow()`.
725
+
726
+ ### 13.7 UI Reliability and UX
727
+
728
+ - Loading states:
729
+ - Show skeleton/loading indicators while scanning folders or reading leaderboard JSON.
730
+ - Empty states:
731
+ - Display β€œNo entries yet” when a leaderboard exists without users or has not been created.
732
+ - Accessibility:
733
+ - Ensure sufficient color contrast, keyboard navigation for tabs/period selectors, and alt text for icons.
734
+ - Internationalization (future):
735
+ - Keep date/time ISO formatting and English labels; design UI to allow future localization.
736
+
737
+ ### 13.8 Ranking and Tie-Breaks (Operational Clarification)
738
+
739
+ - Sort order:
740
+ - Primary: `score` desc; secondary: `time` asc; tertiary: `word_list_difficulty` desc; quaternary: stable by `timestamp` asc.
741
+ - Display limit:
742
+ - Always store full `users` list; apply `max_display_entries` at render time only.
743
+ - Rank reporting:
744
+ - Return rank based on full sorted list even if not displayed; if outside display limit, mark `qualified=False`.
745
+
746
+ ### 13.9 Commit and Retry Strategy (HF)
747
+
748
+ - Commit messages:
749
+ - Format: `leaderboard:{entry_type}:{period_id}:{file_id} add uid={uid} score={score} time={time}`.
750
+ - Retries:
751
+ - Backoff sequence: 0.25s, 0.5s, 1s; max 3 attempts; abort on `LB_SETTINGS_MISMATCH`.
752
+ - Partial failures:
753
+ - If daily succeeds and weekly fails (or vice versa), return both statuses independently; UI reports partial success.
754
+
755
+ ---
wrdler/leaderboard_page.py CHANGED
@@ -9,7 +9,7 @@ __version__ = "0.2.0"
9
 
10
  import streamlit as st
11
  from datetime import datetime
12
- from typing import Optional
13
  import pandas as pd
14
 
15
  from wrdler.leaderboard import (
@@ -44,6 +44,17 @@ def _get_rank_emoji(rank: int) -> str:
44
  return f"{rank}."
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
47
  def _render_leaderboard_table(leaderboard: Optional[LeaderboardSettings], title: str):
48
  """Render a leaderboard as a styled table."""
49
  if title:
@@ -61,7 +72,7 @@ def _render_leaderboard_table(leaderboard: Optional[LeaderboardSettings], title:
61
  for i, user in enumerate(display_users, 1):
62
  rank_display = _get_rank_emoji(i)
63
  time_display = _format_time(user.time)
64
- difficulty = f"{user.word_list_difficulty:.2f}" if user.word_list_difficulty else "-"
65
 
66
  # Show challenge indicator if from a challenge
67
  challenge_badge = " 🎯" if user.source_challenge_id else ""
@@ -118,6 +129,22 @@ def render_leaderboard_page(default_tab: str = "daily"):
118
  game_title = APP_SETTINGS.get("game_title", "Wrdler")
119
  st.title(f"πŸ† {game_title} Leaderboards")
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  # Tab selection - set default based on parameter
122
  tab_names = ["πŸ“… Daily", "πŸ“† Weekly", "πŸ“š History"]
123
 
@@ -139,48 +166,73 @@ def _render_daily_tab():
139
  st.header("πŸ“… Daily Leaderboards")
140
  st.write(f"Top {MAX_DISPLAY_ENTRIES} scores for each day. Resets at UTC midnight.")
141
 
142
- # Get current game settings for filtering
143
- settings = _get_current_game_settings()
144
- st.info(f"Showing leaderboards for: **{settings.game_mode}** mode, **{settings.wordlist_source}**")
145
-
146
- # Get last 7 days
147
- daily_boards = get_last_n_daily_leaderboards(7, settings)
148
 
149
- for date_id, leaderboard in daily_boards:
150
- # Format date nicely
151
  try:
152
  date_obj = datetime.strptime(date_id, "%Y-%m-%d")
153
- if date_id == get_current_daily_id():
154
- title = f"🌟 Today ({date_obj.strftime('%B %d, %Y')})"
155
- else:
156
- title = date_obj.strftime("%A, %B %d, %Y")
157
  except ValueError:
158
- title = date_id
159
-
160
- with st.expander(title, expanded=(date_id == get_current_daily_id())):
161
- _render_leaderboard_table(leaderboard, "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
 
164
  def _render_weekly_tab():
165
  """Render weekly leaderboard tab."""
166
- st.header("πŸ“† Weekly Leaderboard")
167
- st.write(f"Top {MAX_DISPLAY_ENTRIES} scores for the current week. Resets Monday at UTC midnight.")
168
-
169
- # Get current game settings for filtering
170
- settings = _get_current_game_settings()
171
- st.info(f"Showing leaderboard for: **{settings.game_mode}** mode, **{settings.wordlist_source}**")
172
 
173
  weekly_id = get_current_weekly_id()
174
- _, leaderboard = find_matching_leaderboard("weekly", weekly_id, settings)
175
 
176
- # Parse week for display
 
 
 
 
 
177
  try:
178
  year, week = weekly_id.split("-W")
179
- title = f"Week {int(week)}, {year}"
180
  except ValueError:
181
- title = weekly_id
182
 
183
- _render_leaderboard_table(leaderboard, f"πŸ—“οΈ {title}")
 
 
 
 
 
 
 
 
 
 
184
 
185
 
186
  def _render_history_tab():
@@ -200,7 +252,6 @@ def _render_history_tab():
200
  key="history_daily_select"
201
  )
202
 
203
- # Show available settings for this period
204
  settings_list = list_settings_for_period("daily", selected_daily)
205
  if settings_list:
206
  file_id_options = [s["file_id"] for s in settings_list]
@@ -213,7 +264,10 @@ def _render_history_tab():
213
 
214
  if st.button("Load Daily", key="load_daily"):
215
  leaderboard = load_leaderboard("daily", selected_daily, selected_file_id)
216
- _render_leaderboard_table(leaderboard, f"Daily: {selected_daily} ({selected_file_id})")
 
 
 
217
  else:
218
  st.info("No leaderboards found for this date.")
219
  else:
@@ -229,7 +283,6 @@ def _render_history_tab():
229
  key="history_weekly_select"
230
  )
231
 
232
- # Show available settings for this period
233
  settings_list = list_settings_for_period("weekly", selected_weekly)
234
  if settings_list:
235
  file_id_options = [s["file_id"] for s in settings_list]
@@ -242,7 +295,10 @@ def _render_history_tab():
242
 
243
  if st.button("Load Weekly", key="load_weekly"):
244
  leaderboard = load_leaderboard("weekly", selected_weekly, selected_file_id)
245
- _render_leaderboard_table(leaderboard, f"Weekly: {selected_weekly} ({selected_file_id})")
 
 
 
246
  else:
247
  st.info("No leaderboards found for this week.")
248
  else:
 
9
 
10
  import streamlit as st
11
  from datetime import datetime
12
+ from typing import Optional, Tuple
13
  import pandas as pd
14
 
15
  from wrdler.leaderboard import (
 
44
  return f"{rank}."
45
 
46
 
47
+ def _settings_badge(lb: LeaderboardSettings) -> str:
48
+ """Build a compact settings badge for headers."""
49
+ mode = lb.game_mode
50
+ source = lb.wordlist_source
51
+ incorrect = "errors:on" if lb.show_incorrect_guesses else "errors:off"
52
+ free = "free:on" if lb.enable_free_letters else "free:off"
53
+ spacer = lb.puzzle_options.get("spacer", 0)
54
+ overlap = lb.puzzle_options.get("may_overlap", False)
55
+ return f"[{mode} β€’ {source} β€’ {incorrect} β€’ {free} β€’ spacer:{spacer} β€’ overlap:{'yes' if overlap else 'no'}]"
56
+
57
+
58
  def _render_leaderboard_table(leaderboard: Optional[LeaderboardSettings], title: str):
59
  """Render a leaderboard as a styled table."""
60
  if title:
 
72
  for i, user in enumerate(display_users, 1):
73
  rank_display = _get_rank_emoji(i)
74
  time_display = _format_time(user.time)
75
+ difficulty = f"{user.word_list_difficulty:.2f}" if user.word_list_difficulty is not None else "-"
76
 
77
  # Show challenge indicator if from a challenge
78
  challenge_badge = " 🎯" if user.source_challenge_id else ""
 
129
  game_title = APP_SETTINGS.get("game_title", "Wrdler")
130
  st.title(f"πŸ† {game_title} Leaderboards")
131
 
132
+ # Inject CSS to style the active tab
133
+ st.markdown(
134
+ """
135
+ <style>
136
+ /* Target Streamlit tabs: pick the selected tab button/link */
137
+ div[role="tablist"] p {padding: 0.5em 1em !important;}
138
+ div[role="tablist"] [aria-selected="true"] {
139
+ color: #ffffff !important;
140
+ border: 1px solid #4CAF50 !important;
141
+ }
142
+ div[role="tablist"] [aria-selected="true"] p {font-weight: bold !important;}
143
+ </style>
144
+ """,
145
+ unsafe_allow_html=True
146
+ )
147
+
148
  # Tab selection - set default based on parameter
149
  tab_names = ["πŸ“… Daily", "πŸ“† Weekly", "πŸ“š History"]
150
 
 
166
  st.header("πŸ“… Daily Leaderboards")
167
  st.write(f"Top {MAX_DISPLAY_ENTRIES} scores for each day. Resets at UTC midnight.")
168
 
169
+ # Show last 7 days; for each day, show all leaderboards (settings combos)
170
+ daily_periods = list_available_periods("daily", limit=7)
171
+ if not daily_periods:
172
+ st.info("No daily leaderboards found.")
173
+ return
 
174
 
175
+ for date_id in daily_periods:
176
+ # Human friendly date title
177
  try:
178
  date_obj = datetime.strptime(date_id, "%Y-%m-%d")
179
+ is_today = date_id == get_current_daily_id()
180
+ date_title = ("🌟 Today " if is_today else "") + date_obj.strftime("%A, %B %d, %Y")
 
 
181
  except ValueError:
182
+ date_title = date_id
183
+
184
+ # List all settings folders (file_ids) for this period
185
+ settings_list = list_settings_for_period("daily", date_id)
186
+ if not settings_list:
187
+ with st.expander(date_title, expanded=False):
188
+ st.info("No leaderboards for this date.")
189
+ continue
190
+
191
+ # Render one expander per settings combination
192
+ for settings_info in settings_list:
193
+ file_id = settings_info["file_id"]
194
+ lb = load_leaderboard("daily", date_id, file_id)
195
+ header_suffix = ""
196
+ if lb is not None:
197
+ header_suffix = _settings_badge(lb)
198
+ expander_title = f"{date_title} {header_suffix}" if header_suffix else date_title
199
+ with st.expander(expander_title, expanded=(date_id == get_current_daily_id())):
200
+ # Also show a small settings summary above the table
201
+ if lb is not None:
202
+ st.caption(f"Settings: mode={lb.game_mode}, source={lb.wordlist_source}, show_incorrect={lb.show_incorrect_guesses}, free_letters={lb.enable_free_letters}, spacer={lb.puzzle_options.get('spacer', 0)}, overlap={lb.puzzle_options.get('may_overlap', False)}")
203
+ _render_leaderboard_table(lb, "")
204
 
205
 
206
  def _render_weekly_tab():
207
  """Render weekly leaderboard tab."""
208
+ st.header("πŸ“† Weekly Leaderboards")
209
+ st.write(f"Top {MAX_DISPLAY_ENTRIES} scores for each ISO week. Resets Monday at UTC midnight.")
 
 
 
 
210
 
211
  weekly_id = get_current_weekly_id()
 
212
 
213
+ # Show current week; render all settings combinations that exist
214
+ settings_list = list_settings_for_period("weekly", weekly_id)
215
+ if not settings_list:
216
+ st.info("No weekly leaderboards found for the current week.")
217
+ return
218
+
219
  try:
220
  year, week = weekly_id.split("-W")
221
+ week_title = f"Week {int(week)}, {year}"
222
  except ValueError:
223
+ week_title = weekly_id
224
 
225
+ for settings_info in settings_list:
226
+ file_id = settings_info["file_id"]
227
+ lb = load_leaderboard("weekly", weekly_id, file_id)
228
+ header_suffix = ""
229
+ if lb is not None:
230
+ header_suffix = _settings_badge(lb)
231
+ expander_title = f"πŸ—“οΈ {week_title} {header_suffix}" if header_suffix else f"πŸ—“οΈ {week_title}"
232
+ with st.expander(expander_title, expanded=True):
233
+ if lb is not None:
234
+ st.caption(f"Settings: mode={lb.game_mode}, source={lb.wordlist_source}, show_incorrect={lb.show_incorrect_guesses}, free_letters={lb.enable_free_letters}, spacer={lb.puzzle_options.get('spacer', 0)}, overlap={lb.puzzle_options.get('may_overlap', False)}")
235
+ _render_leaderboard_table(lb, "")
236
 
237
 
238
  def _render_history_tab():
 
252
  key="history_daily_select"
253
  )
254
 
 
255
  settings_list = list_settings_for_period("daily", selected_daily)
256
  if settings_list:
257
  file_id_options = [s["file_id"] for s in settings_list]
 
264
 
265
  if st.button("Load Daily", key="load_daily"):
266
  leaderboard = load_leaderboard("daily", selected_daily, selected_file_id)
267
+ header = f"Daily: {selected_daily} "
268
+ if leaderboard is not None:
269
+ header += _settings_badge(leaderboard)
270
+ _render_leaderboard_table(leaderboard, header)
271
  else:
272
  st.info("No leaderboards found for this date.")
273
  else:
 
283
  key="history_weekly_select"
284
  )
285
 
 
286
  settings_list = list_settings_for_period("weekly", selected_weekly)
287
  if settings_list:
288
  file_id_options = [s["file_id"] for s in settings_list]
 
295
 
296
  if st.button("Load Weekly", key="load_weekly"):
297
  leaderboard = load_leaderboard("weekly", selected_weekly, selected_file_id)
298
+ header = f"Weekly: {selected_weekly} "
299
+ if leaderboard is not None:
300
+ header += _settings_badge(leaderboard)
301
+ _render_leaderboard_table(leaderboard, header)
302
  else:
303
  st.info("No leaderboards found for this week.")
304
  else: